Files
gitmost/apps/server/src/integrations/environment/environment.service.ts
claude code agent 227 8201e76c66 fix(git-sync): branch choreography + strict scoping + delete cap (Phase B hardening)
Fixes found by the live pull/push e2e:
- CRITICAL: driveCycle never checked out the 'docmost' branch before
  applyPullActions, so Docmost content was written straight onto 'main',
  clobbering local file edits before push could diff them. Now checkout
  'docmost' before pull (applyPullActions commits there then checks out main +
  merges) — mirrors the engine's pull main(). Round-trip now works both ways.
- add an unresolved-merge guard (SPEC §9): skip the cycle if the vault is
  mid-merge instead of failing on checkout.
- SAFETY: enabledSpaces() is now STRICT opt-in — only spaces with
  settings.gitSync.enabled===true; removed the all-spaces fallback that synced
  every space (incl. a 92-page one) the moment GIT_SYNC_ENABLED flipped.
- SAFETY: per-cycle delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5):
  dry-run the push, and if planned deletes exceed the cap, run the apply with
  deletePage neutralized — phantom absence-deletions from a non-convergent vault
  can't soft-delete real pages. Fails safe if the dry-run throws.
- fix manual trigger: TriggerGitSyncDto.spaceId needs @IsUUID or the global
  whitelist ValidationPipe strips it (arrived undefined -> vault 'undefined').

Live-verified on an isolated flagged space: push (vault file edit -> Docmost
content, stamped lastUpdatedSource='git-sync') and pull (Docmost rename -> vault
file + meta) both work; an unrelated 92-page space stayed untouched throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00

392 lines
11 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
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';
}
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';
}
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).
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);
}
// --- git-sync (plan §7.2) -------------------------------------------------
/** Global master switch for the git-sync control plane (default false). */
isGitSyncEnabled(): boolean {
return (
this.configService.get<string>('GIT_SYNC_ENABLED', 'false').toLowerCase() ===
'true'
);
}
/**
* Root directory holding the per-space vault repos. Defaults to
* `<DATA_DIR or ./data>/git-sync`. `DATA_DIR` is read directly (no dedicated
* getter exists in this codebase) so the vault root tracks the data volume.
*/
getGitSyncDataDir(): string {
const explicit = this.configService.get<string>('GIT_SYNC_DATA_DIR');
if (explicit) return explicit;
const dataDir = this.configService.get<string>('DATA_DIR') || './data';
return `${dataDir.replace(/\/+$/, '')}/git-sync`;
}
/** Optional remote template, e.g. `git@host:vault-{spaceId}.git`. */
getGitSyncRemoteTemplate(): string | undefined {
return this.configService.get<string>('GIT_SYNC_REMOTE_TEMPLATE');
}
/** Poll-safety interval in ms (default 15000). */
getGitSyncPollIntervalMs(): number {
return parseInt(
this.configService.get<string>('GIT_SYNC_POLL_INTERVAL_MS', '15000'),
);
}
/** Event debounce window in ms (default 2000). */
getGitSyncDebounceMs(): number {
return parseInt(
this.configService.get<string>('GIT_SYNC_DEBOUNCE_MS', '2000'),
);
}
/**
* Defense-in-depth absolute cap on how many pages a single push cycle may
* soft-delete (default 5). A non-convergent / phantom-absence cycle whose push
* plan would delete more than this is forced to skip deletions that cycle (the
* orchestrator logs a WARNING). A non-positive or unparseable value falls back
* to the default 5 so the cap can never be silently disabled.
*/
getGitSyncMaxDeletesPerCycle(): number {
const parsed = parseInt(
this.configService.get<string>('GIT_SYNC_MAX_DELETES_PER_CYCLE', '5'),
);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
}
/**
* The service user id git-sync writes are attributed to. Required when sync is
* enabled (validated in environment.validation.ts); optional otherwise.
*/
getGitSyncServiceUserId(): string | undefined {
return this.configService.get<string>('GIT_SYNC_SERVICE_USER_ID');
}
/** Optional path to the SSH key used for git remote access. */
getGitSyncSshKeyPath(): string | undefined {
return this.configService.get<string>('GIT_SYNC_SSH_KEY_PATH');
}
}