Files
gitmost/apps/server/src/integrations/environment/environment.service.ts
T
agent_coder d3209b5aab fix(#355 review E1=B + F1-F8): gate client telemetry OFF by default + throttler/lifecycle/overflow fixes
Maintainer resolved E1 as variant B: the public vitals sink + client collection
must be OFF by default (else client_metrics grows unbounded on a self-host deploy
with no external pruner, via an unauthenticated public endpoint).

- F1: new operator flag CLIENT_TELEMETRY_ENABLED (default OFF), SEPARATE from
  METRICS_PORT (Grafana reads the table directly, independent of the scrape port).
  ClientTelemetryModule.register() provides VitalsController ONLY when the flag is
  true (route absent otherwise); the flag reaches the client via window.CONFIG
  (config.ts isClientTelemetryEnabled), and initVitals() early-returns when off.
- F2/F3 [throttler]: this repo's ThrottlerGuard applies EVERY named throttler to
  every guarded route unless skipped. The new VITALS bucket therefore (a) newly
  bound collab-token → 429 behind shared/NAT IPs, and (b) the vitals route didn't
  skip the stricter public-share-ai (5/min) bucket → effective 5/min not 120.
  Fix (additive, global config unchanged): vitals.controller @SkipThrottle the
  other buckets + @Throttle VITALS 120/min; collab-token adds VITALS_THROTTLER to
  its existing @SkipThrottle (restoring its prior effectively-unthrottled state).
- F4: metrics node:http server is closed on shutdown (MetricsServerLifecycle
  OnModuleDestroy → closeMetricsServer(), fired by enableShutdownHooks).
- F5: docSize outside [0, int4-max] drops to null (keeping the event) instead of
  overflowing int4 and failing the WHOLE batch insert (+ 2 tests).
- F6: .env.example documents METRICS_PORT (no default — unset = subsystem OFF) +
  CLIENT_TELEMETRY_ENABLED; fixed the inaccurate "default 9464" wording.
- F7: disabled/non-sampled sessions install ZERO observers — isVitalsActive()
  (enabled && sampled) gates reportClientMetric AND the page-editor
  measurePageOpen + dispatchTransaction wrapping.
- F8: kept db.d.ts hand-added (wontfix) — this repo HAND-CURATES db.d.ts (verified
  across recent fork migrations a32fba63/8c5b57eb/fdeede00); codegen would be the
  deviation. The ClientMetrics interface maps the migration 1:1.

Gate: server tsc 0, client tsc 0, server metrics/vitals/telemetry/throttle 21
tests, client route-template 5. No new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:00:03 +03:00

432 lines
13 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
private readonly logger = new Logger(EnvironmentService.name);
// Env keys already warned about for an invalid value (one-shot per key, so a
// bad SANDBOX_* value is not logged on every blob put). Mirrors the original
// sandboxTtlWarned guard, generalized across the TTL + the three byte caps.
private readonly invalidPositiveIntWarned = new Set<string>();
constructor(private configService: ConfigService) {}
getNodeEnv(): string {
return this.configService.get<string>('NODE_ENV', 'development');
}
isDevelopment(): boolean {
return this.getNodeEnv() === 'development';
}
getAppUrl(): string {
const rawUrl =
this.configService.get<string>('APP_URL') ||
`http://localhost:${this.getPort()}`;
const { origin } = new URL(rawUrl);
return origin;
}
isHttps(): boolean {
const appUrl = this.configService.get<string>('APP_URL');
try {
const url = new URL(appUrl);
return url.protocol === 'https:';
} catch (error) {
return false;
}
}
getSubdomainHost(): string {
return this.configService.get<string>('SUBDOMAIN_HOST');
}
getPort(): number {
return parseInt(this.configService.get<string>('PORT', '3000'));
}
getAppSecret(): string {
return this.configService.get<string>('APP_SECRET');
}
getDatabaseURL(): string {
return this.configService.get<string>('DATABASE_URL');
}
getDatabaseMaxPool(): number {
return parseInt(this.configService.get<string>('DATABASE_MAX_POOL', '10'));
}
getRedisUrl(): string {
return this.configService.get<string>(
'REDIS_URL',
'redis://localhost:6379',
);
}
getJwtTokenExpiresIn(): string {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
try {
msUntilExpiry = ms(expiresInStr as StringValue);
} catch (err) {
msUntilExpiry = ms('90d');
}
return new Date(Date.now() + msUntilExpiry);
}
getGotenbergUrl(): string | undefined {
return this.configService.get<string>('GOTENBERG_URL');
}
getStorageDriver(): string {
return this.configService.get<string>('STORAGE_DRIVER', 'local');
}
getFileUploadSizeLimit(): string {
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
}
getFileImportSizeLimit(): string {
return this.configService.get<string>('FILE_IMPORT_SIZE_LIMIT', '200mb');
}
getAwsS3AccessKeyId(): string {
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
}
getAwsS3SecretAccessKey(): string {
return this.configService.get<string>('AWS_S3_SECRET_ACCESS_KEY');
}
getAwsS3Region(): string {
return this.configService.get<string>('AWS_S3_REGION');
}
getAwsS3Bucket(): string {
return this.configService.get<string>('AWS_S3_BUCKET');
}
getAwsS3Endpoint(): string {
return this.configService.get<string>('AWS_S3_ENDPOINT');
}
getAwsS3ForcePathStyle(): boolean {
const forcePathStyle = this.configService
.get<string>('AWS_S3_FORCE_PATH_STYLE', 'false')
.toLowerCase();
return forcePathStyle === 'true';
}
getAwsS3Url(): string {
return this.configService.get<string>('AWS_S3_URL');
}
getAzureStorageAccountName(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
}
getAzureStorageContainer(): string {
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
}
getAzureStorageAccountKey(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
}
getAzureStorageEndpoint(): string {
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
}
getAzureStorageUrl(): string {
return this.configService.get<string>('AZURE_STORAGE_URL');
}
getMailDriver(): string {
return this.configService.get<string>('MAIL_DRIVER', 'log');
}
getMailFromAddress(): string {
return this.configService.get<string>('MAIL_FROM_ADDRESS');
}
getMailFromName(): string {
return this.configService.get<string>('MAIL_FROM_NAME', 'Docmost');
}
getMailBlockedRecipientDomains(): string[] {
const raw = this.configService.get<string>(
'MAIL_BLOCKED_RECIPIENT_DOMAINS',
'',
);
return raw
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
}
getSmtpHost(): string {
return this.configService.get<string>('SMTP_HOST');
}
getSmtpPort(): number {
return parseInt(this.configService.get<string>('SMTP_PORT'));
}
getSmtpSecure(): boolean {
const secure = this.configService
.get<string>('SMTP_SECURE', 'false')
.toLowerCase();
return secure === 'true';
}
getSmtpIgnoreTLS(): boolean {
const ignoretls = this.configService
.get<string>('SMTP_IGNORETLS', 'false')
.toLowerCase();
return ignoretls === 'true';
}
getSmtpUsername(): string {
return this.configService.get<string>('SMTP_USERNAME');
}
getSmtpPassword(): string {
return this.configService.get<string>('SMTP_PASSWORD');
}
getPostmarkToken(): string {
return this.configService.get<string>('POSTMARK_TOKEN');
}
getDrawioUrl(): string {
return this.configService.get<string>('DRAWIO_URL');
}
isCloud(): boolean {
const cloudConfig = this.configService
.get<string>('CLOUD', 'false')
.toLowerCase();
return cloudConfig === 'true';
}
isSelfHosted(): boolean {
return !this.isCloud();
}
isCompactPageTreeEnabled(): boolean {
const compactTree = this.configService
.get<string>('COMPACT_PAGE_TREE', 'true')
.toLowerCase();
return compactTree === 'true';
}
/**
* Operator toggle for the public client-telemetry sink (#355). DEFAULT OFF:
* the unauthenticated POST /api/telemetry/vitals endpoint + client vitals
* collection are only wired when this is explicitly true. Kept SEPARATE from
* METRICS_PORT (the server Prometheus half) because Grafana reads the
* `client_metrics` table directly, independent of the scrape port — and
* because `client_metrics` has no app-side retention, so an operator must opt
* in and run an external pruner.
*/
isClientTelemetryEnabled(): boolean {
const enabled = this.configService
.get<string>('CLIENT_TELEMETRY_ENABLED', 'false')
.toLowerCase();
return enabled === 'true';
}
getStripePublishableKey(): string {
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
}
getStripeSecretKey(): string {
return this.configService.get<string>('STRIPE_SECRET_KEY');
}
getStripeWebhookSecret(): string {
return this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
}
getBillingTrialDays(): number {
return parseInt(this.configService.get<string>('BILLING_TRIAL_DAYS', '14'));
}
getCollabUrl(): string {
return this.configService.get<string>('COLLAB_URL');
}
isCollabDisableRedis(): boolean {
const isStandalone = this.configService
.get<string>('COLLAB_DISABLE_REDIS', 'false')
.toLowerCase();
return isStandalone === 'true';
}
isDisableTelemetry(): boolean {
const disable = this.configService
.get<string>('DISABLE_TELEMETRY', 'false')
.toLowerCase();
return disable === 'true';
}
/**
* Deferred tool loading for the in-app AI chat (#332). When enabled, the agent
* sees a compact <tool_catalog> and only CORE tools + the loadTools meta-tool
* are active each step; deferred tools (the fat/rare ones + all external MCP
* tools) load on demand. Defaults to ENABLED — the issue treats deferred
* loading as the new behavior; set AI_CHAT_DEFERRED_TOOLS=false to restore the
* old "all tools always active" behavior.
*/
isAiChatDeferredToolsEnabled(): boolean {
const enabled = this.configService
.get<string>('AI_CHAT_DEFERRED_TOOLS', 'true')
.toLowerCase();
return enabled === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
getSearchDriver(): string {
return this.configService
.get<string>('SEARCH_DRIVER', 'database')
.toLowerCase();
}
getTypesenseUrl(): string {
return this.configService
.get<string>('TYPESENSE_URL', 'http://localhost:8108')
.toLowerCase();
}
getTypesenseApiKey(): string {
return this.configService.get<string>('TYPESENSE_API_KEY');
}
getTypesenseLocale(): string {
return this.configService
.get<string>('TYPESENSE_LOCALE', 'en')
.toLowerCase();
}
// NOTE: AI_*/OPENAI_*/GEMINI_*/OLLAMA_* env getters were removed (D8/§14[M3]):
// provider/model/key config now lives solely in workspace settings +
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
getAiAgentRolesCatalogSource(): string {
// Catalog location: an http(s):// base URL the catalog is fetched from.
// The image ships a per-branch default for this baked in at build time
// (Dockerfile ARG AI_AGENT_ROLES_CATALOG_URL, set per-branch in CI), but it
// is overridable at runtime via the env var (this getter returns that
// runtime value). Local-filesystem sources are no longer supported.
// Empty/unset => the catalog is unavailable (the provider returns 502).
// This is INFRA config (where the catalog lives), not provider/model
// config, so an env var is appropriate.
return this.configService.get<string>('AI_AGENT_ROLES_CATALOG_URL', '');
}
getEventStoreDriver(): string {
return this.configService
.get<string>('EVENT_STORE_DRIVER', 'postgres')
.toLowerCase();
}
getClickHouseUrl(): string {
return this.configService.get<string>('CLICKHOUSE_URL');
}
getSamlDisableRequestedAuthnContext(): boolean {
const disabled = this.configService
.get<string>('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false')
.toLowerCase();
return disabled === 'true';
}
isIframeEmbedAllowed(): boolean {
const allowed = this.configService
.get<string>('IFRAME_EMBED_ALLOWED', 'false')
.toLowerCase();
return allowed === 'true';
}
getIframeAllowedOrigins(): string[] {
const raw = this.configService.get<string>('IFRAME_ALLOWED_ORIGINS', '');
return raw
.split(',')
.map((o) => o.trim())
.filter(Boolean);
}
// --- Blob sandbox (in-RAM ephemeral blob transfer; see SandboxModule) ---
// Base URL the sandbox `uri` is built from. It MUST be reachable over the
// network by the external consumer that fetches the blobs (not a loopback
// address if that consumer is remote). Falls back to APP_URL when unset so a
// single-host deployment works out of the box; set it explicitly when the
// consumer lives on another host.
getSandboxPublicUrl(): string {
const raw =
this.configService.get<string>('SANDBOX_PUBLIC_URL') || this.getAppUrl();
// Drop any trailing slash so `${base}/api/sb/${id}` never doubles up.
return raw.replace(/\/+$/, '');
}
// Parse a REQUIRED positive-integer env (TTL in ms or a byte cap). A
// non-integer or <= 0 value would break the sandbox silently (instant expiry,
// or every put failing against a 0-byte cap), so warn once and fall back to
// the default instead. Blob bodies are never logged.
private getPositiveIntEnv(key: string, def: number): number {
const parsed = parseInt(
this.configService.get<string>(key, String(def)),
10,
);
if (!Number.isInteger(parsed) || parsed <= 0) {
if (!this.invalidPositiveIntWarned.has(key)) {
this.invalidPositiveIntWarned.add(key);
this.logger.warn(
`Invalid ${key} (must be a positive integer); falling back to the ${def} default`,
);
}
return def;
}
return parsed;
}
// Blob time-to-live. Default 1h. The unguessable UUID + this short TTL + TLS
// are the whole capability model (no tokens). A non-positive or non-integer
// value would make every blob expire instantly (silent 404s), so reject it and
// fall back to the 1h default (warned about once to avoid per-put log spam).
getSandboxTtlMs(): number {
return this.getPositiveIntEnv('SANDBOX_TTL_MS', 3_600_000);
}
// Per-blob cap for non-image blobs (the serialized document). Default 8 MiB.
getSandboxMaxBytes(): number {
return this.getPositiveIntEnv('SANDBOX_MAX_BYTES', 8_388_608);
}
// Per-blob cap for mirrored image blobs. Default 20 MiB.
getSandboxMaxImageBytes(): number {
return this.getPositiveIntEnv('SANDBOX_MAX_IMAGE_BYTES', 20_971_520);
}
// RAM guard: total bytes the whole store may hold. Default 128 MiB. On
// overflow the store evicts oldest entries to make room.
getSandboxMaxTotalBytes(): number {
return this.getPositiveIntEnv('SANDBOX_MAX_TOTAL_BYTES', 134_217_728);
}
}