diff --git a/README.md b/README.md index 1ba0e7c9..f79ac41f 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,16 @@ If you prefer to host your own Colanode server, check out the [`hosting/`](hosti - **Storage backend** for user files. Colanode defaults to local filesystem storage, but you can switch to **S3-compatible**, **Google Cloud Storage**, or **Azure Blob Storage** backends by setting `STORAGE_TYPE`. - **Colanode server API**, provided as a Docker image. -All required environment variables for the Colanode server can be found in the [`hosting/docker/docker-compose.yaml`](hosting/docker/docker-compose.yaml) file or [`hosting/kubernetes/README.md`](hosting/kubernetes/README.md) for Kubernetes deployments. +#### Configuration model + +- The server image now ships with a full `config.json`, so most defaults are ready to go without touching env vars. +- The config file is the single source of truth. Use `env://VAR_NAME` to pull sensitive values from env vars, or `file://path/to/secret.pem` to inline the contents of a mounted file (append `?` to make either optional). Only `POSTGRES_URL` and `REDIS_URL` are required out of the box. +- To customize settings: + 1. Copy `apps/server/config.json`, edit it, and mount/bind it when using Docker Compose (see `hosting/docker/docker-compose.yaml`). + 2. For Helm, enable `colanode.configFile.enabled` and pass your file via `--set-file colanode.configFile.data=./config.json` (details in [`hosting/kubernetes/README.md`](hosting/kubernetes/README.md)). + 3. Keep secrets as env vars so you don't have to bake them into JSON; the loader resolves `env://` pointers at runtime. + +Environment variables no longer override regular config fields—only values explicitly tagged with `env://` are read from the environment. Refer to [`hosting/docker/docker-compose.yaml`](hosting/docker/docker-compose.yaml) and [`hosting/kubernetes/README.md`](hosting/kubernetes/README.md) for mounting instructions and the handful of required secrets. ### Running locally diff --git a/apps/server/.env.example b/apps/server/.env.example index cd678456..0220f218 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1,116 +1,37 @@ # ------------------------------------------------------------------ # Example .env for Colanode Server -# Copy this file to ".env" and adjust the values as needed. +# ------------------------------------------------------------------ +# Configuration now comes exclusively from config.json. Environment +# variables are only read when config.json references them via pointers. +# Use: +# env://VAR_NAME → load from env (append ? to make optional) +# file://path/to/file → load file contents (relative to config.json unless absolute) +# This file is therefore just an example of the required secrets pointed to by config.json. # ------------------------------------------------------------------ # ─────────────────────────────────────────────────────────────── -# General Node/Server Config -# ─────────────────────────────────────────────────────────────── -NODE_ENV=production -SERVER_NAME="Colanode Local" -SERVER_AVATAR= -SERVER_MODE=standalone # 'standalone' or 'cluster' -# SERVER_PATH_PREFIX= -# SERVER_CORS_ORIGIN=http://localhost:4000 -# SERVER_CORS_MAX_AGE=7200 - -# ─────────────────────────────────────────────────────────────── -# Logging Config -# Possible values: 'trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent' -# Defalt is 'info' -# ─────────────────────────────────────────────────────────────── -# LOGGING_LEVEL=info - -# ─────────────────────────────────────────────────────────────── -# Account Configuration -# ─────────────────────────────────────────────────────────────── -ACCOUNT_VERIFICATION_TYPE=automatic # automatic | manual | email -ACCOUNT_OTP_TIMEOUT=600 # seconds -# ACCOUNT_GOOGLE_ENABLED=true -# ACCOUNT_GOOGLE_CLIENT_ID= -# ACCOUNT_GOOGLE_CLIENT_SECRET= - -# ─────────────────────────────────────────────────────────────── -# Workspace Configuration -# ─────────────────────────────────────────────────────────────── -# Optional, leave empty for no limits -# WORKSPACE_STORAGE_LIMIT=10737418240 # 10 GB -# WORKSPACE_MAX_FILE_SIZE=104857600 # 100 MB - -# ─────────────────────────────────────────────────────────────── -# User Configuration -# ─────────────────────────────────────────────────────────────── -USER_STORAGE_LIMIT=10737418240 # 10 GB -USER_MAX_FILE_SIZE=104857600 # 100 MB - -# ─────────────────────────────────────────────────────────────── -# PostgreSQL Configuration +# Required secrets referenced by config.json # ─────────────────────────────────────────────────────────────── POSTGRES_URL=postgres://colanode_user:postgrespass123@localhost:5432/colanode_db -# POSTGRES_SSL_REJECT_UNAUTHORIZED=false +REDIS_URL=redis://:your_valkey_password@localhost:6379/0 + +# ─────────────────────────────────────────────────────────────── +# Optional: PostgreSQL TLS (used if config.json points to env://POSTGRES_SSL_*) +# ─────────────────────────────────────────────────────────────── # POSTGRES_SSL_CA= # POSTGRES_SSL_KEY= # POSTGRES_SSL_CERT= # ─────────────────────────────────────────────────────────────── -# Redis Configuration +# Optional: Google OAuth (account.google.* section) # ─────────────────────────────────────────────────────────────── -REDIS_URL=redis://:your_valkey_password@localhost:6379/0 -# Optional configurations - -# REDIS_DB=0 -# REDIS_JOBS_QUEUE_NAME=jobs -# REDIS_JOBS_QUEUE_PREFIX=colanode -# REDIS_TUS_LOCK_PREFIX=colanode:tus:lock -# REDIS_TUS_KV_PREFIX=colanode:tus:kv -# REDIS_EVENTS_CHANNEL=events +# ACCOUNT_GOOGLE_CLIENT_ID= +# ACCOUNT_GOOGLE_CLIENT_SECRET= # ─────────────────────────────────────────────────────────────── -# Storage Configuration -# Supported types: 's3', 'file', 'gcs', 'azure' +# Optional: Email configuration (email section) # ─────────────────────────────────────────────────────────────── -STORAGE_TYPE=file - -# ─────────────────────────────────────────────────────────────── -# S3 Storage Configuration (MinIO, AWS S3, or S3-compatible) -# Required when STORAGE_TYPE=s3 -# ─────────────────────────────────────────────────────────────── -# STORAGE_S3_ENDPOINT=http://localhost:9000 -# STORAGE_S3_ACCESS_KEY=minioadmin -# STORAGE_S3_SECRET_KEY=your_minio_password -# STORAGE_S3_BUCKET=colanode -# STORAGE_S3_REGION=us-east-1 -# STORAGE_S3_FORCE_PATH_STYLE=true - -# ─────────────────────────────────────────────────────────────── -# File Storage Configuration (Local Disk) -# Required when STORAGE_TYPE=file -# ─────────────────────────────────────────────────────────────── -STORAGE_FILE_DIRECTORY=./colanode - -# ─────────────────────────────────────────────────────────────── -# Google Cloud Storage (GCS) Configuration -# Required when STORAGE_TYPE=gcs -# ─────────────────────────────────────────────────────────────── -# STORAGE_GCS_BUCKET=your-gcs-bucket-name -# STORAGE_GCS_PROJECT_ID=your-gcp-project-id -# STORAGE_GCS_CREDENTIALS=/path/to/service-account-key.json - -# ─────────────────────────────────────────────────────────────── -# Azure Blob Storage Configuration -# Required when STORAGE_TYPE=azure -# ─────────────────────────────────────────────────────────────── -# STORAGE_AZURE_ACCOUNT=your-storage-account-name -# STORAGE_AZURE_ACCOUNT_KEY=your-storage-account-key -# STORAGE_AZURE_CONTAINER_NAME=colanode - -# ─────────────────────────────────────────────────────────────── -# SMTP Configuration -# ─────────────────────────────────────────────────────────────── -SMTP_ENABLED=false -# SMTP_HOST=smtp -# SMTP_PORT=1025 -# SMTP_USER= -# SMTP_PASSWORD= -# SMTP_EMAIL_FROM=your_email@example.com -# SMTP_EMAIL_FROM_NAME=Colanode \ No newline at end of file +# EMAIL_FROM= +# EMAIL_SMTP_HOST= +# EMAIL_SMTP_USER= +# EMAIL_SMTP_PASSWORD= diff --git a/apps/server/config.json b/apps/server/config.json new file mode 100644 index 00000000..554f7f19 --- /dev/null +++ b/apps/server/config.json @@ -0,0 +1,215 @@ +{ + "_comment": "Colanode Server Configuration - Copy this file to config.json and customize as needed", + "_migration_guide": { + "precedence": "config.json (plus env:// / file:// pointers) > schema defaults", + "env_syntax": "Use 'env://VAR_NAME' to reference environment variables. Add '?' suffix for optional variables (e.g., 'env://VAR_NAME?')", + "file_syntax": "Use 'file://relative/or/absolute/path' to inline file contents (e.g., PEM certificates). Add '?' to make it optional", + "backward_compatibility": "Environment variables are only read when referenced via env://" + }, + + "server": { + "_comment": "Server branding and networking configuration", + "name": "Colanode Local", + "avatar": null, + "mode": "standalone", + "pathPrefix": null, + "cors": { + "origin": "http://localhost:4000", + "maxAge": 7200 + } + }, + + "account": { + "_comment": "User account and authentication configuration", + "verificationType": "automatic", + "otpTimeout": 600, + "google": { + "enabled": false, + "_comment_when_enabled": "When enabled=true, you must provide clientId and clientSecret", + "clientId": "env://ACCOUNT_GOOGLE_CLIENT_ID?", + "clientSecret": "env://ACCOUNT_GOOGLE_CLIENT_SECRET?" + } + }, + + "user": { + "_comment": "Per-user storage limits (in bytes)", + "storageLimit": "10737418240", + "maxFileSize": "104857600" + }, + + "postgres": { + "_comment": "PostgreSQL database connection", + "url": "env://POSTGRES_URL", + "ssl": { + "rejectUnauthorized": null, + "ca": "env://POSTGRES_SSL_CA?", + "key": "env://POSTGRES_SSL_KEY?", + "cert": "env://POSTGRES_SSL_CERT?" + } + }, + + "redis": { + "_comment": "Redis connection and namespacing", + "url": "env://REDIS_URL", + "db": 0, + "jobs": { + "name": "jobs", + "prefix": "colanode" + }, + "tus": { + "lockPrefix": "colanode:tus:lock", + "kvPrefix": "colanode:tus:kv" + }, + "eventsChannel": "events" + }, + + "storage": { + "_comment": "File storage backend configuration. Supports: 'file' (local filesystem), 's3' (S3-compatible like AWS S3, MinIO, Cloudflare R2), 'gcs' (Google Cloud Storage), 'azure' (Azure Blob Storage). For file storage: set 'directory' to the path where user files will be stored. For other backends, set STORAGE_TYPE env var and provide backend-specific credentials via env:// references", + "type": "file", + "directory": "./colanode" + }, + + "email": { + "_comment": "Email sending configuration. Supported providers: smtp", + "enabled": false, + "from": { + "email": "env://EMAIL_FROM?", + "name": "Colanode" + }, + "provider": { + "type": "smtp", + "host": "env://EMAIL_SMTP_HOST?", + "port": 587, + "secure": false, + "auth": { + "user": "env://EMAIL_SMTP_USER?", + "password": "env://EMAIL_SMTP_PASSWORD?" + } + } + }, + + "ai": { + "_comment": "AI features (RAG, embeddings, etc.)", + "enabled": false, + "_comment_when_enabled": "When enabled=true, configure providers, models, and embedding settings below", + "nodeEmbeddingDelay": 5000, + "documentEmbeddingDelay": 10000, + "providers": { + "openai": { + "enabled": false, + "apiKey": "env://OPENAI_API_KEY?" + }, + "google": { + "enabled": false, + "apiKey": "env://GOOGLE_API_KEY?" + } + }, + "langfuse": { + "enabled": false, + "publicKey": "env://LANGFUSE_PUBLIC_KEY?", + "secretKey": "env://LANGFUSE_SECRET_KEY?", + "baseUrl": "https://cloud.langfuse.com" + }, + "models": { + "queryRewrite": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + }, + "response": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + }, + "rerank": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + }, + "summarization": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + }, + "contextEnhancer": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + }, + "noContext": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + }, + "intentRecognition": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + }, + "databaseFilter": { + "provider": "openai", + "modelName": "gpt-4o", + "temperature": 0.5 + } + }, + "embedding": { + "provider": "openai", + "modelName": "text-embedding-3-large", + "dimensions": 2000, + "apiKey": "env://EMBEDDING_API_KEY?", + "batchSize": 50 + }, + "chunking": { + "defaultChunkSize": 1000, + "defaultOverlap": 200, + "enhanceWithContext": false + }, + "retrieval": { + "hybridSearch": { + "semanticSearchWeight": 0.7, + "keywordSearchWeight": 0.3, + "maxResults": 20 + } + } + }, + + "jobs": { + "_comment": "Background job configurations", + "nodeUpdatesMerge": { + "enabled": false, + "_comment_when_enabled": "Merges node update events periodically", + "cron": "0 5 */2 * * *", + "batchSize": 500, + "mergeWindow": 3600, + "cutoffWindow": 7200 + }, + "documentUpdatesMerge": { + "enabled": false, + "_comment_when_enabled": "Merges document update events periodically", + "cron": "0 5 */2 * * *", + "batchSize": 500, + "mergeWindow": 3600, + "cutoffWindow": 7200 + }, + "cleanup": { + "enabled": false, + "_comment_when_enabled": "Cleans up expired upload chunks", + "cron": "0 5 */2 * * *" + } + }, + + "logging": { + "_comment": "Logging level: trace, debug, info, warn, error, fatal, silent", + "level": "info" + }, + + "workspace": { + "_comment": "Per-workspace storage limits (optional)", + "storageLimit": "21474836480", + "maxFileSize": "524288000" + }, + + "debug": { + "filePointerExample": "file://debug-file-example.txt" + } +} diff --git a/apps/server/src/lib/config/account.ts b/apps/server/src/lib/config/account.ts index 26f0e78f..086d92fd 100644 --- a/apps/server/src/lib/config/account.ts +++ b/apps/server/src/lib/config/account.ts @@ -34,15 +34,3 @@ export const accountConfigSchema = z.object({ }); export type AccountConfig = z.infer; - -export const readAccountConfigVariables = () => { - return { - verificationType: process.env.ACCOUNT_VERIFICATION_TYPE, - otpTimeout: process.env.ACCOUNT_OTP_TIMEOUT, - google: { - enabled: process.env.ACCOUNT_GOOGLE_ENABLED === 'true', - clientId: process.env.ACCOUNT_GOOGLE_CLIENT_ID, - clientSecret: process.env.ACCOUNT_GOOGLE_CLIENT_SECRET, - }, - }; -}; diff --git a/apps/server/src/lib/config/ai.ts b/apps/server/src/lib/config/ai.ts index cc577a52..021e7d2a 100644 --- a/apps/server/src/lib/config/ai.ts +++ b/apps/server/src/lib/config/ai.ts @@ -85,93 +85,3 @@ export const aiConfigSchema = z.discriminatedUnion('enabled', [ ]); export type AiConfig = z.infer; - -export const readAiConfigVariables = () => { - return { - enabled: false, - }; - - // return { - // enabled: process.env.AI_ENABLED === 'true', - // nodeEmbeddingDelay: process.env.AI_NODE_EMBEDDING_DELAY, - // documentEmbeddingDelay: process.env.AI_DOCUMENT_EMBEDDING_DELAY, - // providers: { - // openai: { - // apiKey: process.env.OPENAI_API_KEY, - // enabled: process.env.OPENAI_ENABLED, - // }, - // google: { - // apiKey: process.env.GOOGLE_API_KEY, - // enabled: process.env.GOOGLE_ENABLED, - // }, - // }, - // langfuse: { - // enabled: process.env.LANGFUSE_ENABLED, - // publicKey: process.env.LANGFUSE_PUBLIC_KEY, - // secretKey: process.env.LANGFUSE_SECRET_KEY, - // baseUrl: process.env.LANGFUSE_BASE_URL, - // }, - // models: { - // queryRewrite: { - // provider: process.env.QUERY_REWRITE_PROVIDER, - // modelName: process.env.QUERY_REWRITE_MODEL, - // temperature: process.env.QUERY_REWRITE_TEMPERATURE, - // }, - // response: { - // provider: process.env.RESPONSE_PROVIDER, - // modelName: process.env.RESPONSE_MODEL, - // temperature: process.env.RESPONSE_TEMPERATURE, - // }, - // rerank: { - // provider: process.env.RERANK_PROVIDER, - // modelName: process.env.RERANK_MODEL, - // temperature: process.env.RERANK_TEMPERATURE, - // }, - // summarization: { - // provider: process.env.SUMMARIZATION_PROVIDER, - // modelName: process.env.SUMMARIZATION_MODEL, - // temperature: process.env.SUMMARIZATION_TEMPERATURE, - // }, - // contextEnhancer: { - // provider: process.env.CHUNK_CONTEXT_PROVIDER, - // modelName: process.env.CHUNK_CONTEXT_MODEL, - // temperature: process.env.CHUNK_CONTEXT_TEMPERATURE, - // }, - // noContext: { - // provider: process.env.NO_CONTEXT_PROVIDER, - // modelName: process.env.NO_CONTEXT_MODEL, - // temperature: process.env.NO_CONTEXT_TEMPERATURE, - // }, - // intentRecognition: { - // provider: process.env.INTENT_RECOGNITION_PROVIDER, - // modelName: process.env.INTENT_RECOGNITION_MODEL, - // temperature: process.env.INTENT_RECOGNITION_TEMPERATURE, - // }, - // databaseFilter: { - // provider: process.env.DATABASE_FILTER_PROVIDER, - // modelName: process.env.DATABASE_FILTER_MODEL, - // temperature: process.env.DATABASE_FILTER_TEMPERATURE, - // }, - // }, - // embedding: { - // provider: process.env.EMBEDDING_PROVIDER, - // modelName: process.env.EMBEDDING_MODEL, - // dimensions: process.env.EMBEDDING_DIMENSIONS, - // apiKey: process.env.EMBEDDING_API_KEY, - // batchSize: process.env.EMBEDDING_BATCH_SIZE, - // }, - // chunking: { - // defaultChunkSize: process.env.CHUNK_DEFAULT_CHUNK_SIZE, - // defaultOverlap: process.env.CHUNK_DEFAULT_OVERLAP, - // enhanceWithContext: process.env.CHUNK_ENHANCE_WITH_CONTEXT, - // }, - // retrieval: { - // hybridSearch: { - // semanticSearchWeight: - // process.env.RETRIEVAL_HYBRID_SEARCH_SEMANTIC_WEIGHT, - // keywordSearchWeight: process.env.RETRIEVAL_HYBRID_SEARCH_KEYWORD_WEIGHT, - // maxResults: process.env.RETRIEVAL_HYBRID_SEARCH_MAX_RESULTS, - // }, - // }, - // }; -}; diff --git a/apps/server/src/lib/config/email.ts b/apps/server/src/lib/config/email.ts new file mode 100644 index 00000000..c287023d --- /dev/null +++ b/apps/server/src/lib/config/email.ts @@ -0,0 +1,44 @@ +import { z } from 'zod/v4'; + +const smtpProviderConfigSchema = z.object({ + type: z.literal('smtp'), + host: z + .string({ + error: 'EMAIL_SMTP_HOST is required when SMTP provider is used', + }) + .default('smtp'), + port: z.coerce.number().default(587), + secure: z.boolean().default(false), + auth: z.object({ + user: z.string({ + error: 'EMAIL_SMTP_USER is required when SMTP provider is used', + }), + password: z.string({ + error: 'EMAIL_SMTP_PASSWORD is required when SMTP provider is used', + }), + }), +}); + +export const emailProviderConfigSchema = z.discriminatedUnion('type', [ + smtpProviderConfigSchema, +]); + +export type EmailProviderConfig = z.infer; + +export const emailConfigSchema = z.discriminatedUnion('enabled', [ + z.object({ + enabled: z.literal(true), + from: z.object({ + email: z.string({ + error: 'EMAIL_FROM is required when email is enabled', + }), + name: z.string().default('Colanode'), + }), + provider: emailProviderConfigSchema, + }), + z.object({ + enabled: z.literal(false), + }), +]); + +export type EmailConfig = z.infer; diff --git a/apps/server/src/lib/config/index.ts b/apps/server/src/lib/config/index.ts index 223a225e..1644ee38 100644 --- a/apps/server/src/lib/config/index.ts +++ b/apps/server/src/lib/config/index.ts @@ -1,19 +1,17 @@ import { z } from 'zod/v4'; -import { accountConfigSchema, readAccountConfigVariables } from './account'; -import { aiConfigSchema, readAiConfigVariables } from './ai'; -import { jobsConfigSchema, readJobsConfigVariables } from './jobs'; -import { loggingConfigSchema, readLoggingConfigVariables } from './logging'; -import { postgresConfigSchema, readPostgresConfigVariables } from './postgres'; -import { readRedisConfigVariables, redisConfigSchema } from './redis'; -import { readServerConfigVariables, serverConfigSchema } from './server'; -import { readSmtpConfigVariables, smtpConfigSchema } from './smtp'; -import { readStorageConfigVariables, storageConfigSchema } from './storage'; -import { readUserConfigVariables, userConfigSchema } from './user'; -import { - readWorkspaceConfigVariables, - workspaceConfigSchema, -} from './workspace'; +import { accountConfigSchema } from './account'; +import { aiConfigSchema } from './ai'; +import { emailConfigSchema } from './email'; +import { jobsConfigSchema } from './jobs'; +import { loadRawConfig } from './loader'; +import { loggingConfigSchema } from './logging'; +import { postgresConfigSchema } from './postgres'; +import { redisConfigSchema } from './redis'; +import { serverConfigSchema } from './server'; +import { storageConfigSchema } from './storage'; +import { userConfigSchema } from './user'; +import { workspaceConfigSchema } from './workspace'; const configSchema = z.object({ server: serverConfigSchema, @@ -22,7 +20,7 @@ const configSchema = z.object({ postgres: postgresConfigSchema, redis: redisConfigSchema, storage: storageConfigSchema, - smtp: smtpConfigSchema, + email: emailConfigSchema, ai: aiConfigSchema, jobs: jobsConfigSchema, logging: loggingConfigSchema, @@ -33,20 +31,7 @@ export type Configuration = z.infer; const readConfigVariables = (): Configuration => { try { - const input = { - server: readServerConfigVariables(), - account: readAccountConfigVariables(), - user: readUserConfigVariables(), - postgres: readPostgresConfigVariables(), - redis: readRedisConfigVariables(), - storage: readStorageConfigVariables(), - smtp: readSmtpConfigVariables(), - ai: readAiConfigVariables(), - jobs: readJobsConfigVariables(), - logging: readLoggingConfigVariables(), - workspace: readWorkspaceConfigVariables(), - }; - + const input = loadRawConfig(); return configSchema.parse(input); } catch (error) { if (error instanceof z.ZodError) { diff --git a/apps/server/src/lib/config/jobs.ts b/apps/server/src/lib/config/jobs.ts index 088e8cac..fffb9553 100644 --- a/apps/server/src/lib/config/jobs.ts +++ b/apps/server/src/lib/config/jobs.ts @@ -52,26 +52,3 @@ export const jobsConfigSchema = z.object({ }); export type JobsConfig = z.infer; - -export const readJobsConfigVariables = () => { - return { - nodeUpdatesMerge: { - enabled: process.env.JOBS_NODE_UPDATES_MERGE_ENABLED === 'true', - cron: process.env.JOBS_NODE_UPDATES_MERGE_CRON, - batchSize: process.env.JOBS_NODE_UPDATES_MERGE_BATCH_SIZE, - mergeWindow: process.env.JOBS_NODE_UPDATES_MERGE_MERGE_WINDOW, - cutoffWindow: process.env.JOBS_NODE_UPDATES_MERGE_CUTOFF_WINDOW, - }, - documentUpdatesMerge: { - enabled: process.env.JOBS_DOCUMENT_UPDATES_MERGE_ENABLED === 'true', - cron: process.env.JOBS_DOCUMENT_UPDATES_MERGE_CRON, - batchSize: process.env.JOBS_DOCUMENT_UPDATES_MERGE_BATCH_SIZE, - mergeWindow: process.env.JOBS_DOCUMENT_UPDATES_MERGE_MERGE_WINDOW, - cutoffWindow: process.env.JOBS_DOCUMENT_UPDATES_MERGE_CUTOFF_WINDOW, - }, - cleanup: { - enabled: process.env.JOBS_CLEANUP_ENABLED === 'true', - cron: process.env.JOBS_CLEANUP_CRON, - }, - }; -}; diff --git a/apps/server/src/lib/config/loader.ts b/apps/server/src/lib/config/loader.ts new file mode 100644 index 00000000..c4d20ba9 --- /dev/null +++ b/apps/server/src/lib/config/loader.ts @@ -0,0 +1,176 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'node:url'; +import { Configuration } from './index'; + +type ConfigSource = Partial; + +const ENV_POINTER_PATTERN = /^env:\/\/([A-Z0-9_]+)(\?)?$/; +const FILE_POINTER_PATTERN = /^file:\/\/(.+?)(\?)?$/; + +export class MissingEnvVarError extends Error { + constructor(varName: string) { + super(`Missing required environment variable: ${varName}`); + this.name = 'MissingEnvVarError'; + } +} + +export class MissingFileError extends Error { + constructor(filePath: string) { + super(`Missing required configuration file: ${filePath}`); + this.name = 'MissingFileError'; + } +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +type NormalizeContext = { + configDir: string; +}; + +const normalizeValue = (value: unknown, ctx: NormalizeContext): unknown => { + if (value === null) { + return undefined; + } + + if (typeof value === 'string') { + return resolvePointerValue(value, ctx); + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeValue(item, ctx)); + } + + if (isRecord(value)) { + const normalized: Record = {}; + + for (const [key, nested] of Object.entries(value)) { + // Skip fields starting with underscore (documentation fields) + if (key.startsWith('_')) { + continue; + } + + const processed = normalizeValue(nested, ctx); + + if (processed !== undefined) { + normalized[key] = processed; + } + } + + return normalized; + } + + return value; +}; + +const candidateConfigDirectories = (): string[] => { + const cwd = process.cwd(); + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const serverRoot = path.resolve(moduleDir, '../../..'); + const candidates = [cwd, path.join(cwd, 'apps/server'), serverRoot]; + + return Array.from(new Set(candidates.map((dir) => path.resolve(dir)))); +}; + +const findConfigFile = (filename: string): string | undefined => { + for (const dir of candidateConfigDirectories()) { + const candidate = path.join(dir, filename); + + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return undefined; +}; + +const readJsonFile = (filePath: string): Record => { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + + if (!isRecord(parsed)) { + throw new Error('configuration file must contain a JSON object'); + } + + return parsed; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read ${filePath}: ${reason}`); + } +}; + +const resolvePointerValue = (value: string, ctx: NormalizeContext): unknown => { + const envMatch = value.match(ENV_POINTER_PATTERN); + + if (envMatch) { + const [, envName, optionalFlag] = envMatch; + const envValue = process.env[envName!]; + const optional = optionalFlag === '?'; + + if (envValue === undefined) { + if (optional) { + return undefined; + } + + throw new MissingEnvVarError(envName!); + } + + return envValue; + } + + const fileMatch = value.match(FILE_POINTER_PATTERN); + + if (fileMatch) { + const [, rawPath, optionalFlag] = fileMatch; + const optional = optionalFlag === '?'; + const resolvedPath = path.isAbsolute(rawPath!) + ? rawPath! + : path.resolve(ctx.configDir, rawPath!); + + if (!fs.existsSync(resolvedPath)) { + if (optional) { + return undefined; + } + + throw new MissingFileError(resolvedPath); + } + + try { + return fs.readFileSync(resolvedPath, 'utf-8'); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read ${resolvedPath}: ${reason}`); + } + } + + return value; +}; + +export const resolveEnvPointers = ( + value: unknown, + ctx: NormalizeContext +): unknown => { + return normalizeValue(value, ctx); +}; + +export const loadRawConfig = (): ConfigSource => { + const configPath = findConfigFile('config.json'); + + if (!configPath) { + throw new Error( + [ + 'Unable to find config.json.', + 'Copy apps/server/config.json (or mount your own) so the server has a configuration file.', + ].join(' ') + ); + } + + const jsonConfig = readJsonFile(configPath) as ConfigSource; + + return normalizeValue(jsonConfig, { + configDir: path.dirname(configPath), + }) as ConfigSource; +}; diff --git a/apps/server/src/lib/config/logging.ts b/apps/server/src/lib/config/logging.ts index b8c22c24..8a84b14e 100644 --- a/apps/server/src/lib/config/logging.ts +++ b/apps/server/src/lib/config/logging.ts @@ -7,9 +7,3 @@ export const loggingConfigSchema = z.object({ }); export type LoggingConfig = z.infer; - -export const readLoggingConfigVariables = () => { - return { - level: process.env.LOGGING_LEVEL, - }; -}; diff --git a/apps/server/src/lib/config/postgres.ts b/apps/server/src/lib/config/postgres.ts index 05539085..e38288f7 100644 --- a/apps/server/src/lib/config/postgres.ts +++ b/apps/server/src/lib/config/postgres.ts @@ -17,15 +17,3 @@ export const postgresConfigSchema = z.object({ }); export type PostgresConfig = z.infer; - -export const readPostgresConfigVariables = () => { - return { - url: process.env.POSTGRES_URL, - ssl: { - rejectUnauthorized: process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED, - ca: process.env.POSTGRES_SSL_CA, - key: process.env.POSTGRES_SSL_KEY, - cert: process.env.POSTGRES_SSL_CERT, - }, - }; -}; diff --git a/apps/server/src/lib/config/redis.ts b/apps/server/src/lib/config/redis.ts index 4fc2c0ff..a820d62f 100644 --- a/apps/server/src/lib/config/redis.ts +++ b/apps/server/src/lib/config/redis.ts @@ -15,19 +15,3 @@ export const redisConfigSchema = z.object({ }); export type RedisConfig = z.infer; - -export const readRedisConfigVariables = () => { - return { - url: process.env.REDIS_URL, - db: process.env.REDIS_DB, - jobs: { - name: process.env.REDIS_JOBS_NAME, - prefix: process.env.REDIS_JOBS_PREFIX, - }, - tus: { - lockPrefix: process.env.REDIS_TUS_LOCK_PREFIX, - kvPrefix: process.env.REDIS_TUS_KV_PREFIX, - }, - eventsChannel: process.env.REDIS_EVENTS_CHANNEL, - }; -}; diff --git a/apps/server/src/lib/config/server.ts b/apps/server/src/lib/config/server.ts index 3f54f9f5..25b8b0de 100644 --- a/apps/server/src/lib/config/server.ts +++ b/apps/server/src/lib/config/server.ts @@ -19,18 +19,3 @@ export const serverConfigSchema = z.object({ }); export type ServerConfig = z.infer; - -export const readServerConfigVariables = () => { - return { - version: build.version, - sha: build.sha, - name: process.env.SERVER_NAME, - avatar: process.env.SERVER_AVATAR, - mode: process.env.SERVER_MODE, - pathPrefix: process.env.SERVER_PATH_PREFIX, - cors: { - origin: process.env.SERVER_CORS_ORIGIN, - maxAge: process.env.SERVER_CORS_MAX_AGE, - }, - }; -}; diff --git a/apps/server/src/lib/config/smtp.ts b/apps/server/src/lib/config/smtp.ts deleted file mode 100644 index bc156c29..00000000 --- a/apps/server/src/lib/config/smtp.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from 'zod/v4'; - -export const smtpConfigSchema = z.discriminatedUnion('enabled', [ - z.object({ - enabled: z.literal(true), - host: z.string({ - error: 'SMTP_HOST is required when SMTP is enabled', - }), - port: z.coerce.number().default(587), - secure: z.boolean().default(false), - user: z.string({ - error: 'SMTP_USER is required when SMTP is enabled', - }), - password: z.string({ - error: 'SMTP_PASSWORD is required when SMTP is enabled', - }), - from: z.object({ - email: z.string({ - error: 'SMTP_EMAIL_FROM is required when SMTP is enabled', - }), - name: z.string().default('Colanode'), - }), - }), - z.object({ - enabled: z.literal(false), - }), -]); - -export type SmtpConfig = z.infer; - -export const readSmtpConfigVariables = () => { - return { - enabled: process.env.SMTP_ENABLED === 'true', - host: process.env.SMTP_HOST, - port: process.env.SMTP_PORT, - secure: process.env.SMTP_SECURE === 'true', - user: process.env.SMTP_USER, - password: process.env.SMTP_PASSWORD, - from: { - email: process.env.SMTP_EMAIL_FROM, - name: process.env.SMTP_NAME, - }, - }; -}; diff --git a/apps/server/src/lib/config/storage.ts b/apps/server/src/lib/config/storage.ts index 3d967dd2..68ffb274 100644 --- a/apps/server/src/lib/config/storage.ts +++ b/apps/server/src/lib/config/storage.ts @@ -26,7 +26,9 @@ const azureStorageConfigSchema = z.object({ type: z.literal('azure'), account: z.string({ error: 'STORAGE_AZURE_ACCOUNT is required' }), accountKey: z.string({ error: 'STORAGE_AZURE_ACCOUNT_KEY is required' }), - containerName: z.string({ error: 'STORAGE_AZURE_CONTAINER_NAME is required' }), + containerName: z.string({ + error: 'STORAGE_AZURE_CONTAINER_NAME is required', + }), }); export const storageConfigSchema = z.discriminatedUnion('type', [ @@ -41,49 +43,3 @@ export type S3StorageConfig = z.infer; export type FileStorageConfig = z.infer; export type GCSStorageConfig = z.infer; export type AzureStorageConfig = z.infer; - -export const readStorageConfigVariables = (): StorageConfig => { - const storageType = process.env.STORAGE_TYPE || 's3'; - - switch (storageType) { - case 's3': - return { - type: 's3', - endpoint: process.env.STORAGE_S3_ENDPOINT, - accessKey: process.env.STORAGE_S3_ACCESS_KEY, - secretKey: process.env.STORAGE_S3_SECRET_KEY, - bucket: process.env.STORAGE_S3_BUCKET, - region: process.env.STORAGE_S3_REGION, - forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true', - } as z.infer; - case 'file': - return { - type: 'file', - directory: process.env.STORAGE_FILE_DIRECTORY, - } as z.infer; - case 'gcs': - return { - type: 'gcs', - bucket: process.env.STORAGE_GCS_BUCKET, - projectId: process.env.STORAGE_GCS_PROJECT_ID, - credentials: process.env.STORAGE_GCS_CREDENTIALS, - } as z.infer; - case 'azure': - return { - type: 'azure', - account: process.env.STORAGE_AZURE_ACCOUNT, - accountKey: process.env.STORAGE_AZURE_ACCOUNT_KEY, - containerName: process.env.STORAGE_AZURE_CONTAINER_NAME, - } as z.infer; - default: - return { - type: 's3', - endpoint: process.env.STORAGE_S3_ENDPOINT, - accessKey: process.env.STORAGE_S3_ACCESS_KEY, - secretKey: process.env.STORAGE_S3_SECRET_KEY, - bucket: process.env.STORAGE_S3_BUCKET, - region: process.env.STORAGE_S3_REGION, - forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true', - } as z.infer; - } -}; diff --git a/apps/server/src/lib/config/user.ts b/apps/server/src/lib/config/user.ts index 42c062c9..a0b0fddb 100644 --- a/apps/server/src/lib/config/user.ts +++ b/apps/server/src/lib/config/user.ts @@ -6,10 +6,3 @@ export const userConfigSchema = z.object({ }); export type UserConfig = z.infer; - -export const readUserConfigVariables = () => { - return { - storageLimit: process.env.USER_STORAGE_LIMIT, - maxFileSize: process.env.USER_MAX_FILE_SIZE, - }; -}; diff --git a/apps/server/src/lib/config/workspace.ts b/apps/server/src/lib/config/workspace.ts index a6df67b5..a8f0dbac 100644 --- a/apps/server/src/lib/config/workspace.ts +++ b/apps/server/src/lib/config/workspace.ts @@ -6,10 +6,3 @@ export const workspaceConfigSchema = z.object({ }); export type WorkspaceConfig = z.infer; - -export const readWorkspaceConfigVariables = () => { - return { - storageLimit: process.env.WORKSPACE_STORAGE_LIMIT, - maxFileSize: process.env.WORKSPACE_MAX_FILE_SIZE, - }; -}; diff --git a/apps/server/src/services/email-service.ts b/apps/server/src/services/email-service.ts index 87984c8a..ddb581cc 100644 --- a/apps/server/src/services/email-service.ts +++ b/apps/server/src/services/email-service.ts @@ -17,27 +17,40 @@ class EmailService { private from: string | undefined; public async init() { - if (!config.smtp.enabled) { - logger.debug('SMTP configuration is not set, skipping initialization'); + if (!config.email.enabled) { + logger.debug('Email configuration is not set, skipping initialization'); return; } - this.from = `${config.smtp.from.name} <${config.smtp.from.email}>`; - this.transporter = nodemailer.createTransport({ - host: config.smtp.host, - port: config.smtp.port, - secure: config.smtp.secure, - auth: { - user: config.smtp.user, - pass: config.smtp.password, - }, - }); + this.from = `${config.email.from.name} <${config.email.from.email}>`; + const provider = config.email.provider; + + switch (provider.type) { + case 'smtp': + this.transporter = nodemailer.createTransport({ + host: provider.host, + port: provider.port, + secure: provider.secure, + auth: { + user: provider.auth.user, + pass: provider.auth.password, + }, + }); + break; + default: + this.transporter = undefined; + } + + if (!this.transporter) { + logger.warn('Email provider could not be configured'); + return; + } await this.transporter.verify(); } public async sendEmail(message: EmailMessage): Promise { - if (!this.transporter || !this.from) { + if (!config.email.enabled || !this.transporter || !this.from) { logger.debug('Email service not initialized, skipping email send'); return; } diff --git a/hosting/docker/docker-compose.yaml b/hosting/docker/docker-compose.yaml index 20f34753..6d6fde56 100644 --- a/hosting/docker/docker-compose.yaml +++ b/hosting/docker/docker-compose.yaml @@ -59,9 +59,10 @@ services: # --------------------------------------------------------------- # This service runs Mailpit, a local SMTP testing tool. # If you want to test how emails are sent in the 'server' service, - # you can uncomment the 'smtp' service block and configure the - # SMTP_ENABLED variable to 'true' in the 'server' service environment - # variables. + # uncomment the 'smtp' service block, set email.enabled=true in config.json, + # point the email provider to "smtp", and define EMAIL_HOST / + # EMAIL_SMTP_USER / EMAIL_SMTP_PASSWORD env vars (and any others) so the + # SMTP provider block in config.json resolves via env:// pointers. # # Access the Mailpit UI at http://localhost:8025 # --------------------------------------------------------------- @@ -84,129 +85,33 @@ services: - valkey # - smtp # Optional environment: - # ─────────────────────────────────────────────────────────────── - # General Node/Server Config - # ─────────────────────────────────────────────────────────────── + # ------------------------------------------------------------------ + # Configuration Strategy + # ------------------------------------------------------------------ + # - All defaults live in config.json mounted at /app/apps/server/config.json + # - config.json references secrets via env://VAR or read values from files via file://path + # - The variables below are only those secrets; every other setting + # must be changed inside the JSON file + # ------------------------------------------------------------------ NODE_ENV: production - # The server requires a name and avatar URL which will be displayed in the desktop app login screen. - SERVER_NAME: 'Colanode Local' - SERVER_AVATAR: '' - # Possible values for SERVER_MODE: 'standalone', 'cluster' - SERVER_MODE: 'standalone' + # Required env:// secrets (config.json marks these as env://VAR) + POSTGRES_URL: postgres://colanode_user:postgrespass123@postgres:5432/colanode_db + REDIS_URL: redis://:your_valkey_password@valkey:6379/0 - # Optional custom path prefix for the server. - # Add a plain text without any slashes. For example if you set 'colanode' - # the URL 'https://localhost:3000/config' will be: 'https://localhost:3000/colanode/config' - # SERVER_PATH_PREFIX: 'colanode' - - # Optional CORS Configuration. By default the server is accessible from 'http://localhost:4000'. - # You can change this to allow custom origins (use comma to separate multiple origins) or '*' to allow all origins. - # SERVER_CORS_ORIGIN: 'http://localhost:4000' - # SERVER_CORS_MAX_AGE: '7200' - - # ─────────────────────────────────────────────────────────────── - # Logging Configuration - # ─────────────────────────────────────────────────────────────── - # Possible values for LOGGING_LEVEL: 'trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent' - # Default: 'info' - # LOGGING_LEVEL: 'debug' - - # ─────────────────────────────────────────────────────────────── - # Account Configuration - # ─────────────────────────────────────────────────────────────── - # Possible values for ACCOUNT_VERIFICATION_TYPE: 'automatic', 'manual', 'email' - ACCOUNT_VERIFICATION_TYPE: 'automatic' - ACCOUNT_OTP_TIMEOUT: '600' # in seconds - - # If you want to enable Google login, you need to set the following variables: - # ACCOUNT_GOOGLE_ENABLED: 'true' - # ACCOUNT_GOOGLE_CLIENT_ID: 'your_google_client_id' - # ACCOUNT_GOOGLE_CLIENT_SECRET: 'your_google_client_secret' - - # ─────────────────────────────────────────────────────────────── - # Workspace Configuration - # ─────────────────────────────────────────────────────────────── - # Optional, leave empty for no limits - # WORKSPACE_STORAGE_LIMIT: '10737418240' # 10 GB - # WORKSPACE_MAX_FILE_SIZE: '104857600' # 100 MB - - # ─────────────────────────────────────────────────────────────── - # User Configuration - # ─────────────────────────────────────────────────────────────── - USER_STORAGE_LIMIT: '10737418240' # 10 GB - USER_MAX_FILE_SIZE: '104857600' # 100 MB - - # ─────────────────────────────────────────────────────────────── - # PostgreSQL Configuration - # ─────────────────────────────────────────────────────────────── - # The server expects a PostgreSQL database with the pgvector extension installed. - POSTGRES_URL: 'postgres://colanode_user:postgrespass123@postgres:5432/colanode_db' - - # Optional variables for SSL connection to the database - # POSTGRES_SSL_REJECT_UNAUTHORIZED: 'false' - # POSTGRES_SSL_CA: '' - # POSTGRES_SSL_KEY: '' - # POSTGRES_SSL_CERT: '' - - # ─────────────────────────────────────────────────────────────── - # Redis Configuration - # ─────────────────────────────────────────────────────────────── - REDIS_URL: 'redis://:your_valkey_password@valkey:6379/0' - REDIS_DB: '0' - # Optional variables: - # REDIS_JOBS_QUEUE_NAME: 'jobs' - # REDIS_JOBS_QUEUE_PREFIX: 'colanode' - # REDIS_TUS_LOCK_PREFIX: 'colanode:tus:lock' - # REDIS_TUS_KV_PREFIX: 'colanode:tus:kv' - # REDIS_EVENTS_CHANNEL: 'events' - - # ─────────────────────────────────────────────────────────────── - # Storage configuration - # Supported storage types: 'file', 's3', 'gcs', 'azure' - # By default we store files on the local filesystem. - # ─────────────────────────────────────────────────────────────── - STORAGE_TYPE: 'file' - STORAGE_FILE_DIRECTORY: '/var/lib/colanode/storage' - # To use the optional MinIO service (or any S3-compatible storage): - # STORAGE_TYPE: 's3' - # STORAGE_S3_ENDPOINT: 'http://minio:9000' - # STORAGE_S3_ACCESS_KEY: 'minioadmin' - # STORAGE_S3_SECRET_KEY: 'your_minio_password' - # STORAGE_S3_BUCKET: 'colanode' - # STORAGE_S3_REGION: 'us-east-1' - # STORAGE_S3_FORCE_PATH_STYLE: 'true' - - # ─────────────────────────────────────────────────────────────── - # SMTP configuration - # --------------------------------------------------------------- - # We leave the SMTP configuration disabled by default. - # --------------------------------------------------------------- - SMTP_ENABLED: 'false' - # --------------------------------------------------------------- - # If using the local Mailpit service (defined above), use: - # SMTP_ENABLED: 'true' - # SMTP_HOST: 'smtp' - # SMTP_PORT: '1025' - # SMTP_USER: '' - # SMTP_PASSWORD: '' - # SMTP_EMAIL_FROM: 'your_email@example.com' - # SMTP_EMAIL_FROM_NAME: 'Colanode' - # --------------------------------------------------------------- - # If using a real SMTP provider, update these: - # SMTP_ENABLED: 'true' - # SMTP_HOST: 'your_smtp_provider_host' - # SMTP_PORT: '587' # Or 465, etc. - # SMTP_USER: 'your_smtp_username' - # SMTP_PASSWORD: 'your_smtp_password' - # SMTP_EMAIL_FROM: 'your_email@example.com' - # SMTP_EMAIL_FROM_NAME: 'Colanode' - # --------------------------------------------------------------- + # Optional env://? secrets – include only the entries referenced in JSON + # Use file://path entries in config.json when you want to read the value from mounted files instead. + # ACCOUNT_GOOGLE_CLIENT_ID: '' + # ACCOUNT_GOOGLE_CLIENT_SECRET: '' + # ... ports: - '3000:3000' volumes: - server_storage:/var/lib/colanode/storage + # Mount a config.json sitting next to this compose file to override defaults. + # If omitted, the already included config.json inside the server image is used. + - ./config.json:/app/apps/server/config.json:ro networks: - colanode_network diff --git a/hosting/kubernetes/README.md b/hosting/kubernetes/README.md index 67391277..152f3768 100644 --- a/hosting/kubernetes/README.md +++ b/hosting/kubernetes/README.md @@ -39,19 +39,23 @@ helm install my-colanode ./hosting/kubernetes/chart # Install with custom values helm install my-colanode ./hosting/kubernetes/chart \ --set colanode.ingress.hosts[0].host=colanode.example.com \ - --set colanode.config.SERVER_NAME="My Colanode Instance" + --set colanode.configFile.enabled=true \ + --set-file colanode.configFile.data=./config.json ``` ## Configuration ### Core Settings -| Parameter | Description | Default | -| ----------------------------- | --------------------------- | ------------------------- | -| `colanode.replicaCount` | Number of Colanode replicas | `1` | -| `colanode.image.repository` | Colanode image repository | `ghcr.io/colanode/server` | -| `colanode.image.tag` | Colanode image tag | `latest` | -| `colanode.config.SERVER_NAME` | Server display name | `Colanode K8s` | +| Parameter | Description | Default | +| ---------------------------- | ---------------------------------------------------------- | ------------------------- | +| `colanode.replicaCount` | Number of Colanode replicas | `1` | +| `colanode.image.repository` | Colanode image repository | `ghcr.io/colanode/server` | +| `colanode.image.tag` | Colanode image tag | `latest` | +| `colanode.nodeEnv` | Value exported as `NODE_ENV` inside the server pod | `production` | +| `colanode.additionalEnv` | Extra env vars consumed via `env://` pointers | `[]` | +| `colanode.extraVolumeMounts` | Additional pod volume mounts (pairs with `extraVolumes`) | `[]` | +| `colanode.extraVolumes` | Extra `volumes` entries (Secrets/ConfigMaps for `file://`) | `[]` | ### Ingress Configuration @@ -69,6 +73,44 @@ helm install my-colanode ./hosting/kubernetes/chart \ | `redis.enabled` | Enable Redis deployment | `true` | | `minio.enabled` | Enable bundled MinIO (only required for the in-cluster S3 option) | `false` | +### Using config.json with Helm + +- The server image already ships with a default `config.json`. Only two env vars are strictly required: `POSTGRES_URL` and `REDIS_URL` (because the JSON references them via `env://`). +- If you do not override `config.json`, the bundled file still expects those pointers. The chart wires them up automatically via `POSTGRES_URL=env://POSTGRES_URL` and `REDIS_URL=env://REDIS_URL`, so a vanilla install works without extra values. +- To supply your own JSON file, copy `apps/server/config.json`, edit it, and enable the new override: + + ```bash + helm install my-colanode ./hosting/kubernetes/chart \ + --set colanode.configFile.enabled=true \ + --set-file colanode.configFile.data=./config.json + ``` + +- Alternatively, create a ConfigMap yourself (`kubectl create configmap colanode-config --from-file=config.json`) and set `colanode.configFile.existingConfigMap=colanode-config`. +- Environment variables no longer override config values. Only secrets referenced via `env://` (and values from files via `file://`) are read at runtime. Keep non-secret settings in your JSON, mount it with `colanode.configFile`, and surface additional env vars through `colanode.additionalEnv` when a pointer needs a value from Kubernetes secrets. +- To use `file://` pointers, mount the target files next to `config.json` (the chart stores it at `/app/apps/server/config.json`). For example, to load a PostgreSQL CA cert via `"file://secrets/postgres-ca.crt"`: + 1. Create a secret with the cert contents: + + ```bash + kubectl create secret generic postgres-ca \ + --from-file=postgres-ca.crt=./certs/rootCA.crt + ``` + + 2. Mount the secret and expose it inside the pod: + + ```yaml + colanode: + extraVolumes: + - name: postgres-ca + secret: + secretName: postgres-ca + extraVolumeMounts: + - name: postgres-ca + mountPath: /app/apps/server/secrets + readOnly: true + ``` + + 3. Point your `config.json` field to `"file://secrets/postgres-ca.crt"`. The loader resolves the path relative to the directory containing `config.json`. + ### Storage Configuration Set `colanode.storage.type` to choose where user files and avatars are stored: diff --git a/hosting/kubernetes/chart/templates/_helpers.tpl b/hosting/kubernetes/chart/templates/_helpers.tpl index 07f4a647..2266942b 100644 --- a/hosting/kubernetes/chart/templates/_helpers.tpl +++ b/hosting/kubernetes/chart/templates/_helpers.tpl @@ -89,6 +89,19 @@ Return the default PVC name used for file storage {{- printf "%s-storage" (include "colanode.fullname" .) -}} {{- end }} +{{/* +Return the config.json ConfigMap name +*/}} +{{- define "colanode.configJsonConfigMapName" -}} +{{- if .Values.colanode.configFile.existingConfigMap -}} +{{ .Values.colanode.configFile.existingConfigMap }} +{{- else if .Values.colanode.configFile.name }} +{{ .Values.colanode.configFile.name }} +{{- else }} +{{ printf "%s-config-json" (include "colanode.fullname" .) }} +{{- end }} +{{- end }} + {{/* Helper to get value from secret key reference or direct value Usage: {{ include "colanode.getValueOrSecret" (dict "key" "theKey" "value" .Values.path.to.value) }} @@ -127,55 +140,11 @@ value: {{ $value.value | quote }} Colanode Server Environment Variables */}} {{- define "colanode.serverEnvVars" -}} -# ─────────────────────────────────────────────────────────────── -# General Node/Server Config -# ─────────────────────────────────────────────────────────────── - name: NODE_ENV - value: {{ .Values.colanode.config.NODE_ENV | quote }} + value: {{ default "production" .Values.colanode.nodeEnv | quote }} - name: PORT value: {{ .Values.colanode.service.port | quote }} -- name: SERVER_NAME - value: {{ .Values.colanode.config.SERVER_NAME | quote }} -- name: SERVER_AVATAR - value: {{ .Values.colanode.config.SERVER_AVATAR | quote }} -- name: SERVER_MODE - value: {{ .Values.colanode.config.SERVER_MODE | quote }} -# ─────────────────────────────────────────────────────────────── -# Logging Configuration -# ─────────────────────────────────────────────────────────────── -- name: LOGGING_LEVEL - value: {{ .Values.colanode.config.LOGGING_LEVEL | quote }} - -# ─────────────────────────────────────────────────────────────── -# Account Configuration -# ─────────────────────────────────────────────────────────────── -- name: ACCOUNT_VERIFICATION_TYPE - value: {{ .Values.colanode.config.ACCOUNT_VERIFICATION_TYPE | quote }} -- name: ACCOUNT_OTP_TIMEOUT - value: {{ .Values.colanode.config.ACCOUNT_OTP_TIMEOUT | quote }} -- name: ACCOUNT_ALLOW_GOOGLE_LOGIN - value: {{ .Values.colanode.config.ACCOUNT_ALLOW_GOOGLE_LOGIN | quote }} - -# ─────────────────────────────────────────────────────────────── -# Workspace Configuration -# ─────────────────────────────────────────────────────────────── -- name: WORKSPACE_STORAGE_LIMIT - value: {{ .Values.colanode.config.WORKSPACE_STORAGE_LIMIT | quote }} -- name: WORKSPACE_MAX_FILE_SIZE - value: {{ .Values.colanode.config.WORKSPACE_MAX_FILE_SIZE | quote }} - -# ─────────────────────────────────────────────────────────────── -# User Configuration -# ─────────────────────────────────────────────────────────────── -- name: USER_STORAGE_LIMIT - value: {{ .Values.colanode.config.USER_STORAGE_LIMIT | quote }} -- name: USER_MAX_FILE_SIZE - value: {{ .Values.colanode.config.USER_MAX_FILE_SIZE | quote }} - -# ─────────────────────────────────────────────────────────────── -# PostgreSQL Configuration -# ─────────────────────────────────────────────────────────────── - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: @@ -184,9 +153,6 @@ Colanode Server Environment Variables - name: POSTGRES_URL value: "postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "colanode.postgresql.hostname" . }}:5432/{{ .Values.postgresql.auth.database }}" -# ─────────────────────────────────────────────────────────────── -# Redis/Valkey Configuration -# ─────────────────────────────────────────────────────────────── - name: REDIS_PASSWORD {{- if .Values.redis.auth.existingSecret }} {{- include "colanode.getRequiredValueOrSecret" (dict @@ -202,114 +168,43 @@ Colanode Server Environment Variables key: {{ .Values.redis.auth.secretKeys.redisPasswordKey }} {{- end }} - name: REDIS_URL - value: "redis://:$(REDIS_PASSWORD)@{{ include "colanode.redis.hostname" . }}:6379/{{ .Values.colanode.config.REDIS_DB }}" -- name: REDIS_DB - value: {{ .Values.colanode.config.REDIS_DB | quote }} -- name: REDIS_JOBS_QUEUE_NAME - value: {{ .Values.colanode.config.REDIS_JOBS_QUEUE_NAME | quote }} -- name: REDIS_JOBS_QUEUE_PREFIX - value: {{ .Values.colanode.config.REDIS_JOBS_QUEUE_PREFIX | quote }} -- name: REDIS_TUS_LOCK_PREFIX - value: {{ .Values.colanode.config.REDIS_TUS_LOCK_PREFIX | quote }} -- name: REDIS_TUS_KV_PREFIX - value: {{ .Values.colanode.config.REDIS_TUS_KV_PREFIX | quote }} -- name: REDIS_EVENTS_CHANNEL - value: {{ .Values.colanode.config.REDIS_EVENTS_CHANNEL | quote }} + value: "redis://:$(REDIS_PASSWORD)@{{ include "colanode.redis.hostname" . }}:6379/0" -# ─────────────────────────────────────────────────────────────── -# Storage Configuration -# ─────────────────────────────────────────────────────────────── -- name: STORAGE_TYPE - value: {{ default "file" .Values.colanode.storage.type | quote }} -{{- $storageType := default "file" .Values.colanode.storage.type }} -{{- if eq $storageType "file" }} -- name: STORAGE_FILE_DIRECTORY - value: {{ required "colanode.storage.file.directory must be set when STORAGE_TYPE is file" .Values.colanode.storage.file.directory | quote }} -{{- end }} -{{- if eq $storageType "s3" }} -{{- $s3 := .Values.colanode.storage.s3 }} -{{- $endpoint := $s3.endpoint }} -{{- if and (not $endpoint) (not .Values.minio.enabled) }} -{{- fail "colanode.storage.s3.endpoint must be provided when MinIO is disabled" }} -{{- end }} -- name: STORAGE_S3_ENDPOINT - value: {{ if $endpoint }}{{ $endpoint | quote }}{{ else }}{{ printf "http://%s:9000" (include "colanode.minio.hostname" .) | quote }}{{ end }} -- name: STORAGE_S3_BUCKET - value: {{ required "colanode.storage.s3.bucket must be set when STORAGE_TYPE is s3" $s3.bucket | quote }} -- name: STORAGE_S3_REGION - value: {{ required "colanode.storage.s3.region must be set when STORAGE_TYPE is s3" $s3.region | quote }} -- name: STORAGE_S3_FORCE_PATH_STYLE - value: {{ ternary "true" "false" (default true $s3.forcePathStyle) | quote }} -- name: STORAGE_S3_ACCESS_KEY -{{- if or $s3.accessKey.value $s3.accessKey.existingSecret }} - {{- include "colanode.getRequiredValueOrSecret" (dict "key" "colanode.storage.s3.accessKey" "value" $s3.accessKey) | nindent 2 }} -{{- else if .Values.minio.enabled }} +{{- range $index, $env := .Values.colanode.additionalEnv }} +- name: {{ required (printf "colanode.additionalEnv[%d].name is required" $index) $env.name }} + {{- if hasKey $env "valueFrom" }} valueFrom: - secretKeyRef: - name: {{ if .Values.minio.auth.existingSecret }}{{ .Values.minio.auth.existingSecret }}{{ else }}{{ printf "%s-minio" .Release.Name }}{{ end }} - key: {{ .Values.minio.auth.rootUserKey }} -{{- else }} - {{- fail "An S3 access key must be provided via colanode.storage.s3.accessKey when STORAGE_TYPE is s3 and MinIO is disabled" }} -{{- end }} -- name: STORAGE_S3_SECRET_KEY -{{- if or $s3.secretKey.value $s3.secretKey.existingSecret }} - {{- include "colanode.getRequiredValueOrSecret" (dict "key" "colanode.storage.s3.secretKey" "value" $s3.secretKey) | nindent 2 }} -{{- else if .Values.minio.enabled }} - valueFrom: - secretKeyRef: - name: {{ if .Values.minio.auth.existingSecret }}{{ .Values.minio.auth.existingSecret }}{{ else }}{{ printf "%s-minio" .Release.Name }}{{ end }} - key: {{ .Values.minio.auth.rootPasswordKey }} -{{- else }} - {{- fail "An S3 secret key must be provided via colanode.storage.s3.secretKey when STORAGE_TYPE is s3 and MinIO is disabled" }} -{{- end }} -{{- end }} -{{- if eq $storageType "gcs" }} -{{- $gcs := .Values.colanode.storage.gcs }} -- name: STORAGE_GCS_BUCKET - value: {{ required "colanode.storage.gcs.bucket must be set when STORAGE_TYPE is gcs" $gcs.bucket | quote }} -- name: STORAGE_GCS_PROJECT_ID - value: {{ required "colanode.storage.gcs.projectId must be set when STORAGE_TYPE is gcs" $gcs.projectId | quote }} -{{- if $gcs.credentialsSecret.name }} -- name: STORAGE_GCS_CREDENTIALS - value: {{ printf "%s/%s" (trimSuffix "/" $gcs.credentialsSecret.mountPath) $gcs.credentialsSecret.fileName | quote }} -{{- else if $gcs.credentialsPath }} -- name: STORAGE_GCS_CREDENTIALS - value: {{ $gcs.credentialsPath | quote }} -{{- else }} -{{- fail "Provide colanode.storage.gcs.credentialsSecret or credentialsPath when STORAGE_TYPE is gcs" }} -{{- end }} -{{- end }} -{{- if eq $storageType "azure" }} -{{- $azure := .Values.colanode.storage.azure }} -- name: STORAGE_AZURE_ACCOUNT - value: {{ required "colanode.storage.azure.account must be set when STORAGE_TYPE is azure" $azure.account | quote }} -- name: STORAGE_AZURE_CONTAINER_NAME - value: {{ required "colanode.storage.azure.containerName must be set when STORAGE_TYPE is azure" $azure.containerName | quote }} -- name: STORAGE_AZURE_ACCOUNT_KEY -{{- if or $azure.accountKey.value $azure.accountKey.existingSecret }} - {{- include "colanode.getRequiredValueOrSecret" (dict "key" "colanode.storage.azure.accountKey" "value" $azure.accountKey) | nindent 2 }} -{{- else }} - {{- fail "An Azure storage account key must be provided via colanode.storage.azure.accountKey when STORAGE_TYPE is azure" }} +{{ toYaml $env.valueFrom | nindent 4 }} + {{- else if hasKey $env "value" }} + value: {{ $env.value | quote }} + {{- else }} + {{- fail (printf "Provide either value or valueFrom for colanode.additionalEnv[%d]" $index) }} + {{- end }} {{- end }} {{- end }} -# ─────────────────────────────────────────────────────────────── -# SMTP configuration -# ─────────────────────────────────────────────────────────────── -- name: SMTP_ENABLED - value: {{ .Values.colanode.config.SMTP_ENABLED | quote }} -{{- if eq .Values.colanode.config.SMTP_ENABLED "true" }} -- name: SMTP_HOST - value: {{ required "colanode.config.SMTP_HOST must be set when SMTP_ENABLED is true" .Values.colanode.config.SMTP_HOST | quote }} -- name: SMTP_PORT - value: {{ required "colanode.config.SMTP_PORT must be set when SMTP_ENABLED is true" .Values.colanode.config.SMTP_PORT | quote }} -- name: SMTP_USER - value: {{ .Values.colanode.config.SMTP_USER | quote }} -- name: SMTP_PASSWORD - value: {{ .Values.colanode.config.SMTP_PASSWORD | quote }} -- name: SMTP_EMAIL_FROM - value: {{ required "colanode.config.SMTP_EMAIL_FROM must be set when SMTP_ENABLED is true" .Values.colanode.config.SMTP_EMAIL_FROM | quote }} -- name: SMTP_EMAIL_FROM_NAME - value: {{ .Values.colanode.config.SMTP_EMAIL_FROM_NAME | quote }} +{{/* +Render extra volume mounts for file:// pointers +*/}} +{{- define "colanode.renderExtraVolumeMounts" -}} +{{- range $mount := . }} +- name: {{ required "colanode.extraVolumeMounts[].name is required" $mount.name }} + mountPath: {{ required (printf "Specify mountPath for extraVolumeMount %s" $mount.name) $mount.mountPath }} +{{- with $mount.subPath }} + subPath: {{ . }} +{{- end }} +{{- with $mount.readOnly }} + readOnly: {{ . }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Render extra volumes (Secrets/ConfigMaps) for file:// pointers +*/}} +{{- define "colanode.renderExtraVolumes" -}} +{{- range $volume := . }} +- +{{ toYaml $volume | nindent 2 }} {{- end }} {{- end }} diff --git a/hosting/kubernetes/chart/templates/config-json.yaml b/hosting/kubernetes/chart/templates/config-json.yaml new file mode 100644 index 00000000..8b767d0c --- /dev/null +++ b/hosting/kubernetes/chart/templates/config-json.yaml @@ -0,0 +1,20 @@ +{{- $configFile := .Values.colanode.configFile -}} +{{- if and $configFile.enabled (not $configFile.existingConfigMap) }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "colanode.configJsonConfigMapName" . | trim | quote }} + labels: + {{- include "colanode.labels" . | nindent 4 }} +data: + {{ default "config.json" $configFile.key | quote }}: | +{{ if $configFile.data }} +{{- if kindIs "string" $configFile.data }} +{{ $configFile.data | nindent 4 }} +{{- else }} +{{ $configFile.data | toPrettyJson | nindent 4 }} +{{- end }} +{{ else }} + {} +{{ end }} +{{- end }} diff --git a/hosting/kubernetes/chart/templates/deployment.yaml b/hosting/kubernetes/chart/templates/deployment.yaml index 760eece8..dae8190f 100644 --- a/hosting/kubernetes/chart/templates/deployment.yaml +++ b/hosting/kubernetes/chart/templates/deployment.yaml @@ -42,7 +42,13 @@ spec: {{- $mountFileStorage := eq $storageType "file" }} {{- $gcsSecret := .Values.colanode.storage.gcs.credentialsSecret }} {{- $mountGcsCredentials := and (eq $storageType "gcs") $gcsSecret.name }} -{{- if or $mountFileStorage $mountGcsCredentials }} +{{- $configFile := .Values.colanode.configFile }} +{{- $mountConfigFile := or $configFile.enabled $configFile.existingConfigMap }} +{{- $extraVolumeMounts := .Values.colanode.extraVolumeMounts | default (list) }} +{{- $extraVolumes := .Values.colanode.extraVolumes | default (list) }} +{{- $hasExtraVolumeMounts := gt (len $extraVolumeMounts) 0 }} +{{- $hasExtraVolumes := gt (len $extraVolumes) 0 }} +{{- if or $mountFileStorage $mountGcsCredentials $mountConfigFile $hasExtraVolumeMounts }} volumeMounts: {{- if $mountFileStorage }} - name: storage-data @@ -53,10 +59,19 @@ spec: mountPath: {{ required "colanode.storage.gcs.credentialsSecret.mountPath must be set when mounting GCS credentials" $gcsSecret.mountPath }} readOnly: true {{- end }} +{{- if $mountConfigFile }} + - name: config-json + mountPath: /app/apps/server/config.json + subPath: {{ default "config.json" $configFile.key }} + readOnly: true +{{- end }} +{{- if $hasExtraVolumeMounts }} + {{ include "colanode.renderExtraVolumeMounts" $extraVolumeMounts | nindent 12 }} +{{- end }} {{- end }} resources: {{- toYaml .Values.colanode.resources | nindent 12 }} -{{- if or $mountFileStorage $mountGcsCredentials }} +{{- if or $mountFileStorage $mountGcsCredentials $mountConfigFile $hasExtraVolumes }} volumes: {{- if $mountFileStorage }} - name: storage-data @@ -81,4 +96,12 @@ spec: - key: {{ required "colanode.storage.gcs.credentialsSecret.key must be set when providing a secret" $gcsSecret.key }} path: {{ required "colanode.storage.gcs.credentialsSecret.fileName must be set when providing a secret" $gcsSecret.fileName }} {{- end }} +{{- if $mountConfigFile }} + - name: config-json + configMap: + name: {{ include "colanode.configJsonConfigMapName" . | trim | quote }} +{{- end }} +{{- if $hasExtraVolumes }} + {{ include "colanode.renderExtraVolumes" $extraVolumes | nindent 8 }} +{{- end }} {{- end }} diff --git a/hosting/kubernetes/chart/values.yaml b/hosting/kubernetes/chart/values.yaml index f6206d8b..dfcedc09 100644 --- a/hosting/kubernetes/chart/values.yaml +++ b/hosting/kubernetes/chart/values.yaml @@ -71,57 +71,55 @@ colanode: autoscaling: enabled: false - # -- Colanode server configuration - config: - # Core settings - NODE_ENV: production - PORT: 3000 - SERVER_NAME: 'Colanode Kubernetes' - SERVER_AVATAR: '' - SERVER_MODE: 'standalone' + # -- Node runtime mode exposed as NODE_ENV. Even without colanode.configFile, + # the container ships with a default config.json that references env://POSTGRES_URL + # and env://REDIS_URL; this chart wires those automatically. + nodeEnv: production - # Logging Config - # Possible values: 'trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent' - # Defalt is 'info' - # LOGGING_LEVEL=info + # -- Additional env vars consumed via env:// pointers in config.json + # Example (secret reference): + # additionalEnv: + # - name: ACCOUNT_GOOGLE_CLIENT_ID + # valueFrom: + # secretKeyRef: + # name: google-oauth + # key: clientId + additionalEnv: [] - # Account settings - ACCOUNT_VERIFICATION_TYPE: 'automatic' - ACCOUNT_OTP_TIMEOUT: '600' # in seconds - ACCOUNT_ALLOW_GOOGLE_LOGIN: 'false' + # -- Extra volume mounts for file:// pointers (e.g., TLS certs). Combine with + # colanode.extraVolumes below to mount Secrets/ConfigMaps into the pod. + # Example: config.json contains "ca": "file://secrets/postgres-ca.crt". + # extraVolumeMounts: + # - name: postgres-ca + # mountPath: /app/apps/server/secrets + # readOnly: true + extraVolumeMounts: [] - # Workspace limits. Leave empty for no limits - # WORKSPACE_STORAGE_LIMIT: '10737418240' # 10 GB - # WORKSPACE_MAX_FILE_SIZE: '104857600' # 100 MB + # -- Extra volumes referenced by extraVolumeMounts. Each item becomes a standard + # Kubernetes volume definition. The example pairs with the mount above and + # assumes a secret created via `kubectl create secret generic postgres-ca \ + # --from-file=postgres-ca.crt=./certs/rootCA.crt`. + # extraVolumes: + # - name: postgres-ca + # secret: + # secretName: postgres-ca + extraVolumes: [] - # User limits - USER_STORAGE_LIMIT: '10737418240' # 10 GB - USER_MAX_FILE_SIZE: '104857600' # 100 MB - - # Database connection (PostgreSQL) - POSTGRES_URL: 'postgres://colanode_user:$(POSTGRES_PASSWORD)@{{ .Release.Name }}-postgresql:5432/colanode_db' - # Optional SSL settings for PostgreSQL - # POSTGRES_SSL_REJECT_UNAUTHORIZED: "false" - # POSTGRES_SSL_CA: "" - # POSTGRES_SSL_KEY: "" - # POSTGRES_SSL_CERT: "" - - # Redis/Valkey configuration - REDIS_DB: '0' - REDIS_JOBS_QUEUE_NAME: 'jobs' - REDIS_JOBS_QUEUE_PREFIX: 'colanode' - REDIS_TUS_LOCK_PREFIX: 'colanode:tus:lock' - REDIS_TUS_KV_PREFIX: 'colanode:tus:kv' - REDIS_EVENTS_CHANNEL: 'events' - - # Email configuration - SMTP_ENABLED: 'false' - # SMTP_HOST: "" - # SMTP_PORT: "587" - # SMTP_USER: "" - # SMTP_PASSWORD: "" - # SMTP_EMAIL_FROM: "" - # SMTP_EMAIL_FROM_NAME: "Colanode" + # -- Optional config.json override + configFile: + enabled: false + # -- Reference an existing ConfigMap instead of inlining data + existingConfigMap: '' + # -- Name to give the generated ConfigMap (defaults to "-config-json") + name: '' + # -- Key to use inside the ConfigMap + key: config.json + # -- Raw config.json content (use --set-file to load your version). When + # disabled, the baked-in config.json remains in place and already expects + # env://POSTGRES_URL and env://REDIS_URL, both supplied automatically by + # this chart. + data: | + {} storage: # -- Storage backend type. Supported values: file, s3, gcs, azure diff --git a/package-lock.json b/package-lock.json index 7ab4a148..ce502362 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12880,7 +12880,6 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -12891,7 +12890,6 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", - "dev": true, "license": "MIT", "peer": true, "peerDependencies": { @@ -16069,7 +16067,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/custom-error-instance": { @@ -17289,7 +17286,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -27341,7 +27337,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": {