Compare commits

..

10 Commits

Author SHA1 Message Date
f3fe2fad16 fix(#371): resolve TypeScript strictness errors in telemetry tracking
All checks were successful
ci/woodpecker/push/api Pipeline was successful
- llm-cost-table.ts: Add undefined guard for MODEL_COSTS lookup
- llm-telemetry-tracker.service.ts: Allow undefined in callingContext
  for exactOptionalPropertyTypes compatibility

Refs #371

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:07:45 -06:00
3cadf4317c chore: update tasks.md — all M10-Telemetry tasks complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:05:09 -06:00
96ca58e69b feat(#375): frontend token usage and cost dashboard
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/web Pipeline failed
- Install recharts for data visualization
- Add Usage nav item to sidebar navigation
- Create telemetry API service with data fetching functions
- Build dashboard page with summary cards, charts, and time range selector
- Token usage line chart, cost breakdown bar chart, task outcome pie chart
- Loading and empty states handled
- Responsive layout with PDA-friendly design
- Add unit tests (14 tests passing)

Refs #375

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:02:55 -06:00
74a50441cc docs(#376): telemetry integration guide
- Create comprehensive telemetry documentation at docs/telemetry.md
- Cover configuration, event schema, predictions, SDK reference
- Include development guide with dry-run mode and troubleshooting
- Link from main README.md

Refs #376

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:58:01 -06:00
36e6cdd9f9 feat(#372): track orchestrator agent task completions via telemetry
Some checks failed
ci/woodpecker/push/coordinator Pipeline failed
- Instrument Coordinator.process_queue() with timing and telemetry events
- Instrument OrchestrationLoop.process_next_issue() with quality gate tracking
- Add agent-to-telemetry mapping (model, provider, harness per agent name)
- Map difficulty levels to Complexity enum and gate names to QualityGate enum
- Track retry counts per issue (increment on failure, clear on success)
- Emit FAILURE outcome on agent spawn failure or quality gate rejection
- Non-blocking: telemetry errors are logged and swallowed, never delay tasks
- Pass telemetry client from FastAPI lifespan to Coordinator constructor
- Add 33 unit tests covering all telemetry scenarios

Refs #372

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:52:54 -06:00
d5bf501c9c feat(#373): prediction integration for cost estimation
Some checks failed
ci/woodpecker/push/api Pipeline failed
- Create PredictionService for pre-task cost/token estimates
- Refresh common predictions on startup
- Integrate predictions into LLM telemetry tracker
- Add GET /api/telemetry/estimate endpoint
- Graceful degradation when no prediction data available
- Add unit tests for prediction service

Refs #373

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:50:58 -06:00
639881f2b1 feat(#371): track LLM task completions via Mosaic Telemetry
Some checks failed
ci/woodpecker/push/api Pipeline failed
- Create LlmTelemetryTrackerService for non-blocking event emission
- Normalize token usage across Anthropic, OpenAI, Ollama providers
- Add cost table with per-token pricing in microdollars
- Instrument chat, chatStream, and embed methods
- Infer task type from calling context
- Aggregate streaming tokens after stream ends with fallback estimation
- Add 69 unit tests for tracker service, cost table, and LLM service

Refs #371

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:44:29 -06:00
0467f77e55 feat(#374): add telemetry config to docker-compose and .env
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
- Add MOSAIC_TELEMETRY_* variables to .env.example with descriptions
- Pass telemetry env vars to api service in production compose
- Pass telemetry env vars to coordinator service in dev and swarm composes
- Swarm composes default to production URL (https://tel-api.mosaicstack.dev)
- Dev compose includes commented-out telemetry-api service placeholder
- All compose files default MOSAIC_TELEMETRY_ENABLED to false for safety

Refs #374

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:40:06 -06:00
0c4ad7c57d feat(#369): install @mosaicstack/telemetry-client in API
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
- Add .npmrc with scoped Gitea npm registry for @mosaicstack packages
- Create MosaicTelemetryModule (global, lifecycle-aware) at
  apps/api/src/mosaic-telemetry/
- Create MosaicTelemetryService wrapping TelemetryClient with
  convenience methods: trackTaskCompletion, getPrediction,
  refreshPredictions, eventBuilder
- Create mosaic-telemetry.config.ts for env var integration via
  NestJS ConfigService
- Register MosaicTelemetryModule in AppModule
- Add 32 unit tests covering module init, service methods, disabled
  mode, dry-run mode, and lifecycle management

Refs #369

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:36:53 -06:00
83d0cbe0f1 feat(#370): install mosaicstack-telemetry in Coordinator
Some checks failed
ci/woodpecker/push/coordinator Pipeline failed
- Add mosaicstack-telemetry>=0.1.0 to pyproject.toml dependencies
- Configure Gitea PyPI registry via pip.conf (extra-index-url)
- Integrate TelemetryClient in FastAPI lifespan (start_async/stop_async)
- Store client on app.state.mosaic_telemetry for downstream access
- Create mosaic_telemetry.py helper module with:
  - get_telemetry_client(): retrieve client from app state
  - build_task_event(): construct TaskCompletionEvent with coordinator defaults
  - create_telemetry_config(): create config from MOSAIC_TELEMETRY_* env vars
- Add 28 unit tests covering config, helpers, disabled mode, and lifespan
- New module has 100% test coverage

Refs #370

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:33:54 -06:00
551 changed files with 7955 additions and 72776 deletions

View File

@@ -15,19 +15,11 @@ WEB_PORT=3000
# ====================== # ======================
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001 NEXT_PUBLIC_API_URL=http://localhost:3001
# Frontend auth mode:
# - real: Normal auth/session flow
# - mock: Local-only seeded user for FE development (blocked outside NODE_ENV=development)
# Use `mock` locally to continue FE work when auth flow is unstable.
# If omitted, web runtime defaults:
# - development -> mock
# - production -> real
NEXT_PUBLIC_AUTH_MODE=real
# ====================== # ======================
# PostgreSQL Database # PostgreSQL Database
# ====================== # ======================
# Bundled PostgreSQL # Bundled PostgreSQL (when database profile enabled)
# SECURITY: Change POSTGRES_PASSWORD to a strong random password in production # SECURITY: Change POSTGRES_PASSWORD to a strong random password in production
DATABASE_URL=postgresql://mosaic:REPLACE_WITH_SECURE_PASSWORD@postgres:5432/mosaic DATABASE_URL=postgresql://mosaic:REPLACE_WITH_SECURE_PASSWORD@postgres:5432/mosaic
POSTGRES_USER=mosaic POSTGRES_USER=mosaic
@@ -36,7 +28,7 @@ POSTGRES_DB=mosaic
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# External PostgreSQL (managed service) # External PostgreSQL (managed service)
# To use an external instance, update DATABASE_URL above # Disable 'database' profile and point DATABASE_URL to your external instance
# Example: DATABASE_URL=postgresql://user:pass@rds.amazonaws.com:5432/mosaic # Example: DATABASE_URL=postgresql://user:pass@rds.amazonaws.com:5432/mosaic
# PostgreSQL Performance Tuning (Optional) # PostgreSQL Performance Tuning (Optional)
@@ -47,7 +39,7 @@ POSTGRES_MAX_CONNECTIONS=100
# ====================== # ======================
# Valkey Cache (Redis-compatible) # Valkey Cache (Redis-compatible)
# ====================== # ======================
# Bundled Valkey # Bundled Valkey (when cache profile enabled)
VALKEY_URL=redis://valkey:6379 VALKEY_URL=redis://valkey:6379
VALKEY_HOST=valkey VALKEY_HOST=valkey
VALKEY_PORT=6379 VALKEY_PORT=6379
@@ -55,7 +47,7 @@ VALKEY_PORT=6379
VALKEY_MAXMEMORY=256mb VALKEY_MAXMEMORY=256mb
# External Redis/Valkey (managed service) # External Redis/Valkey (managed service)
# To use an external instance, update VALKEY_URL above # Disable 'cache' profile and point VALKEY_URL to your external instance
# Example: VALKEY_URL=redis://elasticache.amazonaws.com:6379 # Example: VALKEY_URL=redis://elasticache.amazonaws.com:6379
# Example with auth: VALKEY_URL=redis://:password@redis.example.com:6379 # Example with auth: VALKEY_URL=redis://:password@redis.example.com:6379
@@ -69,7 +61,7 @@ KNOWLEDGE_CACHE_TTL=300
# Authentication (Authentik OIDC) # Authentication (Authentik OIDC)
# ====================== # ======================
# Set to 'true' to enable OIDC authentication with Authentik # Set to 'true' to enable OIDC authentication with Authentik
# When enabled, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI are required # When enabled, OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET are required
OIDC_ENABLED=false OIDC_ENABLED=false
# Authentik Server URLs (required when OIDC_ENABLED=true) # Authentik Server URLs (required when OIDC_ENABLED=true)
@@ -78,9 +70,9 @@ OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here OIDC_CLIENT_SECRET=your-client-secret-here
# Redirect URI must match what's configured in Authentik # Redirect URI must match what's configured in Authentik
# Development: http://localhost:3001/auth/oauth2/callback/authentik # Development: http://localhost:3001/auth/callback/authentik
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik # Production: https://api.mosaicstack.dev/auth/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik OIDC_REDIRECT_URI=http://localhost:3001/auth/callback/authentik
# Authentik PostgreSQL Database # Authentik PostgreSQL Database
AUTHENTIK_POSTGRES_USER=authentik AUTHENTIK_POSTGRES_USER=authentik
@@ -101,14 +93,6 @@ AUTHENTIK_COOKIE_DOMAIN=.localhost
AUTHENTIK_PORT_HTTP=9000 AUTHENTIK_PORT_HTTP=9000
AUTHENTIK_PORT_HTTPS=9443 AUTHENTIK_PORT_HTTPS=9443
# ======================
# CSRF Protection
# ======================
# CRITICAL: Generate a random secret for CSRF token signing
# Required in production; auto-generated in development (not persistent across restarts)
# Command to generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
CSRF_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING
# ====================== # ======================
# JWT Configuration # JWT Configuration
# ====================== # ======================
@@ -124,17 +108,6 @@ JWT_EXPIRATION=24h
# This is used by BetterAuth for session management and CSRF protection # This is used by BetterAuth for session management and CSRF protection
# Example: openssl rand -base64 32 # Example: openssl rand -base64 32
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
# Optional explicit BetterAuth origin for callback/error URL generation.
# When empty, backend falls back to NEXT_PUBLIC_API_URL.
BETTER_AUTH_URL=
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
TRUSTED_ORIGINS=
# Cookie Domain (for cross-subdomain session sharing)
# Leave empty for single-domain setups. Set to ".example.com" for cross-subdomain.
COOKIE_DOMAIN=
# ====================== # ======================
# Encryption (Credential Security) # Encryption (Credential Security)
@@ -215,9 +188,11 @@ NODE_ENV=development
# Used by docker-compose.yml (pulls images) and docker-swarm.yml # Used by docker-compose.yml (pulls images) and docker-swarm.yml
# For local builds, use docker-compose.build.yml instead # For local builds, use docker-compose.build.yml instead
# Options: # Options:
# - latest: Pull latest images from registry (default, built from main branch) # - dev: Pull development images from registry (default, built from develop branch)
# - latest: Pull latest stable images from registry (built from main branch)
# - <commit-sha>: Use specific commit SHA tag (e.g., 658ec077)
# - <version>: Use specific version tag (e.g., v1.0.0) # - <version>: Use specific version tag (e.g., v1.0.0)
IMAGE_TAG=latest IMAGE_TAG=dev
# ====================== # ======================
# Docker Compose Profiles # Docker Compose Profiles
@@ -253,16 +228,12 @@ MOSAIC_API_DOMAIN=api.mosaic.local
MOSAIC_WEB_DOMAIN=mosaic.local MOSAIC_WEB_DOMAIN=mosaic.local
MOSAIC_AUTH_DOMAIN=auth.mosaic.local MOSAIC_AUTH_DOMAIN=auth.mosaic.local
# External Traefik network name (for upstream mode and swarm) # External Traefik network name (for upstream mode)
# Must match the network name of your existing Traefik instance # Must match the network name of your existing Traefik instance
TRAEFIK_NETWORK=traefik-public TRAEFIK_NETWORK=traefik-public
TRAEFIK_DOCKER_NETWORK=traefik-public
# TLS/SSL Configuration # TLS/SSL Configuration
TRAEFIK_TLS_ENABLED=true TRAEFIK_TLS_ENABLED=true
TRAEFIK_ENTRYPOINT=websecure
# Cert resolver name (leave empty if TLS is handled externally or using self-signed certs)
TRAEFIK_CERTRESOLVER=
# For Let's Encrypt (production): # For Let's Encrypt (production):
TRAEFIK_ACME_EMAIL=admin@example.com TRAEFIK_ACME_EMAIL=admin@example.com
# For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty # For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty
@@ -298,15 +269,6 @@ GITEA_WEBHOOK_SECRET=REPLACE_WITH_RANDOM_WEBHOOK_SECRET
# The coordinator service uses this key to authenticate with the API # The coordinator service uses this key to authenticate with the API
COORDINATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS COORDINATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
# Anthropic API Key (used by coordinator for issue parsing)
# Get your API key from: https://console.anthropic.com/
ANTHROPIC_API_KEY=REPLACE_WITH_ANTHROPIC_API_KEY
# Coordinator tuning
COORDINATOR_POLL_INTERVAL=5.0
COORDINATOR_MAX_CONCURRENT_AGENTS=10
COORDINATOR_ENABLED=true
# ====================== # ======================
# Rate Limiting # Rate Limiting
# ====================== # ======================
@@ -314,19 +276,17 @@ COORDINATOR_ENABLED=true
# TTL is in seconds, limits are per TTL window # TTL is in seconds, limits are per TTL window
# Global rate limit (applies to all endpoints unless overridden) # Global rate limit (applies to all endpoints unless overridden)
# Time window in seconds RATE_LIMIT_TTL=60 # Time window in seconds
RATE_LIMIT_TTL=60 RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
# Requests per window
RATE_LIMIT_GLOBAL_LIMIT=100
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute # Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
RATE_LIMIT_WEBHOOK_LIMIT=60 RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
# Coordinator endpoints (/coordinator/*) — requests per minute # Coordinator endpoints (/coordinator/*)
RATE_LIMIT_COORDINATOR_LIMIT=100 RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring) # Health check endpoints (/coordinator/health)
RATE_LIMIT_HEALTH_LIMIT=300 RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
# Storage backend for rate limiting (redis or memory) # Storage backend for rate limiting (redis or memory)
# redis: Uses Valkey for distributed rate limiting (recommended for production) # redis: Uses Valkey for distributed rate limiting (recommended for production)
@@ -348,40 +308,6 @@ RATE_LIMIT_STORAGE=redis
# multi-tenant isolation. Each Discord bot instance should be configured for # multi-tenant isolation. Each Discord bot instance should be configured for
# a single workspace. # a single workspace.
# ======================
# Matrix Bridge (Optional)
# ======================
# Matrix bot integration for chat-based control via Matrix protocol
# Requires a Matrix account with an access token for the bot user
# Set these AFTER deploying Synapse and creating the bot account.
#
# SECURITY: MATRIX_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Matrix commands will execute within this workspace context for proper
# multi-tenant isolation. Each Matrix bot instance should be configured for
# a single workspace.
MATRIX_HOMESERVER_URL=http://synapse:8008
MATRIX_ACCESS_TOKEN=
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
MATRIX_SERVER_NAME=matrix.woltje.com
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
# MATRIX_WORKSPACE_ID=your-workspace-uuid
# ======================
# Matrix / Synapse Deployment
# ======================
# Domains for Traefik routing to Matrix services
MATRIX_DOMAIN=matrix.woltje.com
ELEMENT_DOMAIN=chat.woltje.com
# Synapse database (created automatically by synapse-db-init in the swarm compose)
SYNAPSE_POSTGRES_DB=synapse
SYNAPSE_POSTGRES_USER=synapse
SYNAPSE_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_SYNAPSE_DB_PASSWORD
# Image tags for Matrix services
SYNAPSE_IMAGE_TAG=latest
ELEMENT_IMAGE_TAG=latest
# ====================== # ======================
# Orchestrator Configuration # Orchestrator Configuration
# ====================== # ======================
@@ -392,17 +318,6 @@ ELEMENT_IMAGE_TAG=latest
# Health endpoints (/health/*) remain unauthenticated # Health endpoints (/health/*) remain unauthenticated
ORCHESTRATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS ORCHESTRATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
# Runtime safety defaults (recommended for low-memory hosts)
MAX_CONCURRENT_AGENTS=2
SESSION_CLEANUP_DELAY_MS=30000
ORCHESTRATOR_QUEUE_NAME=orchestrator-tasks
ORCHESTRATOR_QUEUE_CONCURRENCY=1
ORCHESTRATOR_QUEUE_MAX_RETRIES=3
ORCHESTRATOR_QUEUE_BASE_DELAY_MS=1000
ORCHESTRATOR_QUEUE_MAX_DELAY_MS=60000
SANDBOX_DEFAULT_MEMORY_MB=256
SANDBOX_DEFAULT_CPU_LIMIT=1.0
# ====================== # ======================
# AI Provider Configuration # AI Provider Configuration
# ====================== # ======================
@@ -416,58 +331,17 @@ AI_PROVIDER=ollama
# For remote Ollama: http://your-ollama-server:11434 # For remote Ollama: http://your-ollama-server:11434
OLLAMA_MODEL=llama3.1:latest OLLAMA_MODEL=llama3.1:latest
# Claude API Key # Claude API Configuration (when AI_PROVIDER=claude)
# Required only when AI_PROVIDER=claude. # OPTIONAL: Only required if AI_PROVIDER=claude
# Get your API key from: https://console.anthropic.com/ # Get your API key from: https://console.anthropic.com/
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY # Note: Claude Max subscription users should use AI_PROVIDER=ollama instead
# CLAUDE_API_KEY=sk-ant-...
# OpenAI API Configuration (when AI_PROVIDER=openai) # OpenAI API Configuration (when AI_PROVIDER=openai)
# OPTIONAL: Only required if AI_PROVIDER=openai # OPTIONAL: Only required if AI_PROVIDER=openai
# Get your API key from: https://platform.openai.com/api-keys # Get your API key from: https://platform.openai.com/api-keys
# OPENAI_API_KEY=sk-... # OPENAI_API_KEY=sk-...
# ======================
# Speech Services (STT / TTS)
# ======================
# Speech-to-Text (STT) - Whisper via Speaches
# Set STT_ENABLED=true to enable speech-to-text transcription
# STT_BASE_URL is required when STT_ENABLED=true
STT_ENABLED=true
STT_BASE_URL=http://speaches:8000/v1
STT_MODEL=Systran/faster-whisper-large-v3-turbo
STT_LANGUAGE=en
# Text-to-Speech (TTS) - Default Engine (Kokoro)
# Set TTS_ENABLED=true to enable text-to-speech synthesis
# TTS_DEFAULT_URL is required when TTS_ENABLED=true
TTS_ENABLED=true
TTS_DEFAULT_URL=http://kokoro-tts:8880/v1
TTS_DEFAULT_VOICE=af_heart
TTS_DEFAULT_FORMAT=mp3
# Text-to-Speech (TTS) - Premium Engine (Chatterbox) - Optional
# Higher quality voice cloning engine, disabled by default
# TTS_PREMIUM_URL is required when TTS_PREMIUM_ENABLED=true
TTS_PREMIUM_ENABLED=false
TTS_PREMIUM_URL=http://chatterbox-tts:8881/v1
# Text-to-Speech (TTS) - Fallback Engine (Piper/OpenedAI) - Optional
# Lightweight fallback engine, disabled by default
# TTS_FALLBACK_URL is required when TTS_FALLBACK_ENABLED=true
TTS_FALLBACK_ENABLED=false
TTS_FALLBACK_URL=http://openedai-speech:8000/v1
# Whisper model for Speaches STT engine
SPEACHES_WHISPER_MODEL=Systran/faster-whisper-large-v3-turbo
# Speech Service Limits
# Maximum upload file size in bytes (default: 25MB)
SPEECH_MAX_UPLOAD_SIZE=25000000
# Maximum audio duration in seconds (default: 600 = 10 minutes)
SPEECH_MAX_DURATION_SECONDS=600
# Maximum text length for TTS in characters (default: 4096)
SPEECH_MAX_TEXT_LENGTH=4096
# ====================== # ======================
# Mosaic Telemetry (Task Completion Tracking & Predictions) # Mosaic Telemetry (Task Completion Tracking & Predictions)
# ====================== # ======================

66
.env.prod.example Normal file
View File

@@ -0,0 +1,66 @@
# ==============================================
# Mosaic Stack Production Environment
# ==============================================
# Copy to .env and configure for production deployment
# ======================
# PostgreSQL Database
# ======================
# CRITICAL: Use a strong, unique password
POSTGRES_USER=mosaic
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
POSTGRES_DB=mosaic
POSTGRES_SHARED_BUFFERS=256MB
POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
POSTGRES_MAX_CONNECTIONS=100
# ======================
# Valkey Cache
# ======================
VALKEY_MAXMEMORY=256mb
# ======================
# API Configuration
# ======================
API_PORT=3001
API_HOST=0.0.0.0
# ======================
# Web Configuration
# ======================
WEB_PORT=3000
NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev
# ======================
# Authentication (Authentik OIDC)
# ======================
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
# ======================
# JWT Configuration
# ======================
# CRITICAL: Generate a random secret (openssl rand -base64 32)
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET
JWT_EXPIRATION=24h
# ======================
# Traefik Integration
# ======================
# Set to true if using external Traefik
TRAEFIK_ENABLE=true
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_TLS_ENABLED=true
TRAEFIK_DOCKER_NETWORK=traefik-public
TRAEFIK_CERTRESOLVER=letsencrypt
# Domain configuration
MOSAIC_API_DOMAIN=api.mosaicstack.dev
MOSAIC_WEB_DOMAIN=app.mosaicstack.dev
# ======================
# Optional: Ollama
# ======================
# OLLAMA_ENDPOINT=http://ollama.diversecanvas.com:11434

161
.env.swarm.example Normal file
View File

@@ -0,0 +1,161 @@
# ==============================================
# Mosaic Stack - Docker Swarm Configuration
# ==============================================
# Copy this file to .env for Docker Swarm deployment
# ======================
# Application Ports (Internal)
# ======================
API_PORT=3001
API_HOST=0.0.0.0
WEB_PORT=3000
# ======================
# Domain Configuration (Traefik)
# ======================
# These domains must be configured in your DNS or /etc/hosts
MOSAIC_API_DOMAIN=api.mosaicstack.dev
MOSAIC_WEB_DOMAIN=mosaic.mosaicstack.dev
MOSAIC_AUTH_DOMAIN=auth.mosaicstack.dev
# ======================
# Web Configuration
# ======================
# Use the Traefik domain for the API URL
NEXT_PUBLIC_APP_URL=http://mosaic.mosaicstack.dev
NEXT_PUBLIC_API_URL=http://api.mosaicstack.dev
# ======================
# PostgreSQL Database
# ======================
DATABASE_URL=postgresql://mosaic:REPLACE_WITH_SECURE_PASSWORD@postgres:5432/mosaic
POSTGRES_USER=mosaic
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
POSTGRES_DB=mosaic
POSTGRES_PORT=5432
# PostgreSQL Performance Tuning
POSTGRES_SHARED_BUFFERS=256MB
POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
POSTGRES_MAX_CONNECTIONS=100
# ======================
# Valkey Cache
# ======================
VALKEY_URL=redis://valkey:6379
VALKEY_HOST=valkey
VALKEY_PORT=6379
VALKEY_MAXMEMORY=256mb
# Knowledge Module Cache Configuration
KNOWLEDGE_CACHE_ENABLED=true
KNOWLEDGE_CACHE_TTL=300
# ======================
# Authentication (Authentik OIDC)
# ======================
# NOTE: Authentik services are COMMENTED OUT in docker-compose.swarm.yml by default
# Uncomment those services if you want to run Authentik internally
# Otherwise, use external Authentik by configuring OIDC_* variables below
# External Authentik Configuration (default)
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
# Internal Authentik Configuration (only needed if uncommenting Authentik services)
# Authentik PostgreSQL Database
AUTHENTIK_POSTGRES_USER=authentik
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_POSTGRES_DB=authentik
# Authentik Server Configuration
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
AUTHENTIK_ERROR_REPORTING=false
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_BOOTSTRAP_EMAIL=admin@mosaicstack.dev
AUTHENTIK_COOKIE_DOMAIN=.mosaicstack.dev
# ======================
# JWT Configuration
# ======================
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
JWT_EXPIRATION=24h
# ======================
# Encryption (Credential Security)
# ======================
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_GENERATE_WITH_OPENSSL_RAND_HEX_32
# ======================
# OpenBao Secrets Management
# ======================
OPENBAO_ADDR=http://openbao:8200
OPENBAO_PORT=8200
# For development only - remove in production
OPENBAO_DEV_ROOT_TOKEN_ID=root
# ======================
# Ollama (Optional AI Service)
# ======================
OLLAMA_ENDPOINT=http://ollama:11434
OLLAMA_PORT=11434
OLLAMA_EMBEDDING_MODEL=mxbai-embed-large
# Semantic Search Configuration
SEMANTIC_SEARCH_SIMILARITY_THRESHOLD=0.5
# ======================
# OpenAI API (Optional)
# ======================
# OPENAI_API_KEY=sk-...
# ======================
# Application Environment
# ======================
NODE_ENV=production
# ======================
# Gitea Integration (Coordinator)
# ======================
GITEA_URL=https://git.mosaicstack.dev
GITEA_BOT_USERNAME=mosaic
GITEA_BOT_TOKEN=REPLACE_WITH_COORDINATOR_BOT_API_TOKEN
GITEA_BOT_PASSWORD=REPLACE_WITH_COORDINATOR_BOT_PASSWORD
GITEA_REPO_OWNER=mosaic
GITEA_REPO_NAME=stack
GITEA_WEBHOOK_SECRET=REPLACE_WITH_RANDOM_WEBHOOK_SECRET
COORDINATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
# ======================
# Coordinator Service
# ======================
ANTHROPIC_API_KEY=REPLACE_WITH_ANTHROPIC_API_KEY
COORDINATOR_POLL_INTERVAL=5.0
COORDINATOR_MAX_CONCURRENT_AGENTS=10
COORDINATOR_ENABLED=true
# ======================
# Rate Limiting
# ======================
RATE_LIMIT_TTL=60
RATE_LIMIT_GLOBAL_LIMIT=100
RATE_LIMIT_WEBHOOK_LIMIT=60
RATE_LIMIT_COORDINATOR_LIMIT=100
RATE_LIMIT_HEALTH_LIMIT=300
RATE_LIMIT_STORAGE=redis
# ======================
# Orchestrator Configuration
# ======================
ORCHESTRATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY
# ======================
# Logging & Debugging
# ======================
LOG_LEVEL=info
DEBUG=false

10
.gitignore vendored
View File

@@ -59,13 +59,3 @@ yarn-error.log*
# Orchestrator reports (generated by QA automation, cleaned up after processing) # Orchestrator reports (generated by QA automation, cleaned up after processing)
docs/reports/qa-automation/ docs/reports/qa-automation/
# Repo-local orchestrator runtime artifacts
.mosaic/orchestrator/orchestrator.pid
.mosaic/orchestrator/state.json
.mosaic/orchestrator/tasks.json
.mosaic/orchestrator/matrix_state.json
.mosaic/orchestrator/logs/*.log
.mosaic/orchestrator/results/*
!.mosaic/orchestrator/logs/.gitkeep
!.mosaic/orchestrator/results/.gitkeep

View File

@@ -1,15 +0,0 @@
# Repo Mosaic Linkage
This repository is attached to the machine-wide Mosaic framework.
## Load Order for Agents
1. `~/.config/mosaic/STANDARDS.md`
2. `AGENTS.md` (this repository)
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
## Purpose
- Keep universal standards in `~/.config/mosaic`
- Keep repo-specific behavior in this repo
- Avoid copying large runtime configs into each project

View File

@@ -1,18 +0,0 @@
{
"enabled": true,
"transport": "matrix",
"matrix": {
"control_room_id": "",
"workspace_id": "",
"homeserver_url": "",
"access_token": "",
"bot_user_id": ""
},
"worker": {
"runtime": "codex",
"command_template": "bash scripts/agent/orchestrator-worker.sh {task_file}",
"timeout_seconds": 7200,
"max_attempts": 1
},
"quality_gates": ["pnpm lint", "pnpm typecheck", "pnpm test"]
}

View File

@@ -1 +0,0 @@

View File

@@ -1,90 +0,0 @@
{
"schema_version": 1,
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
"name": "MS21 Multi-Tenant RBAC Data Migration",
"description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack",
"project_path": "/home/jwoltje/src/mosaic-stack",
"created_at": "2026-02-28T17:10:22Z",
"status": "active",
"task_prefix": "MS21",
"quality_gates": "pnpm lint && pnpm build && pnpm test",
"milestone_version": "0.0.21",
"milestones": [
{
"id": "phase-1",
"name": "Schema and Admin API",
"status": "pending",
"branch": "schema-and-admin-api",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-2",
"name": "Break-Glass Authentication",
"status": "pending",
"branch": "break-glass-authentication",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-3",
"name": "Data Migration",
"status": "pending",
"branch": "data-migration",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-4",
"name": "Admin UI",
"status": "pending",
"branch": "admin-ui",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-5",
"name": "RBAC UI Enforcement",
"status": "pending",
"branch": "rbac-ui-enforcement",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-6",
"name": "Verification",
"status": "pending",
"branch": "verification",
"issue_ref": "",
"started_at": "",
"completed_at": ""
}
],
"sessions": [
{
"session_id": "sess-001",
"runtime": "unknown",
"started_at": "2026-02-28T17:48:51Z",
"ended_at": "",
"ended_reason": "",
"milestone_at_end": "",
"tasks_completed": [],
"last_task_id": ""
},
{
"session_id": "sess-002",
"runtime": "unknown",
"started_at": "2026-02-28T20:30:13Z",
"ended_at": "",
"ended_reason": "",
"milestone_at_end": "",
"tasks_completed": [],
"last_task_id": ""
}
]
}

View File

@@ -1 +0,0 @@

View File

@@ -1,8 +0,0 @@
{
"session_id": "sess-002",
"runtime": "unknown",
"pid": 3178395,
"started_at": "2026-02-28T20:30:13Z",
"project_path": "/tmp/ms21-ui-001",
"milestone_id": ""
}

View File

@@ -1,10 +0,0 @@
enabled: false
template: ""
# Set enabled: true and choose one template:
# - typescript-node
# - typescript-nextjs
# - monorepo
#
# Apply manually:
# ~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target <repo>

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# Repo-specific hooks used by scripts/agent/*.sh for Mosaic Stack.
mosaic_hook_session_start() {
echo "[mosaic-stack] Branch: $(git rev-parse --abbrev-ref HEAD)"
echo "[mosaic-stack] Remotes:"
git remote -v | sed 's/^/[mosaic-stack] /'
if command -v node >/dev/null 2>&1; then
echo "[mosaic-stack] Node: $(node -v)"
fi
if command -v pnpm >/dev/null 2>&1; then
echo "[mosaic-stack] pnpm: $(pnpm -v)"
fi
}
mosaic_hook_critical() {
echo "[mosaic-stack] Recent commits:"
git log --oneline --decorate -n 5 | sed 's/^/[mosaic-stack] /'
echo "[mosaic-stack] Open TODO/FIXME markers (top 20):"
rg -n "(TODO|FIXME|HACK|SECURITY)" apps packages plugins docs --glob '!**/node_modules/**' -S \
| head -n 20 \
| sed 's/^/[mosaic-stack] /' \
|| true
}
mosaic_hook_session_end() {
echo "[mosaic-stack] Working tree summary:"
git status --short | sed 's/^/[mosaic-stack] /' || true
}

1
.nvmrc
View File

@@ -1 +0,0 @@
24

View File

@@ -6,7 +6,7 @@
# - npm bundled CVEs (5): npm removed from production Node.js images # - npm bundled CVEs (5): npm removed from production Node.js images
# - Node.js 20 → 24 LTS migration (#367): base images updated # - Node.js 20 → 24 LTS migration (#367): base images updated
# #
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar/minimatch (5 CVEs) # REMAINING: OpenBao (5 CVEs) + Next.js bundled tar (3 CVEs)
# Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6. # Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6.
# === OpenBao false positives === # === OpenBao false positives ===
@@ -17,18 +17,15 @@ CVE-2024-9180 # HIGH: privilege escalation (fixed in 2.0.3)
CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1) CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1)
CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4) CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4)
# === Next.js bundled tar/minimatch CVEs (upstream — waiting on Next.js release) === # === Next.js bundled tar CVEs (upstream — waiting on Next.js release) ===
# Next.js 16.1.6 bundles tar@7.5.2 and minimatch@9.0.5 in next/dist/compiled/ (pre-compiled). # Next.js 16.1.6 bundles tar@7.5.2 in next/dist/compiled/tar/ (pre-compiled).
# These are NOT pnpm dependencies — they're embedded in the Next.js package itself. # This is NOT a pnpm dependency — it's embedded in the Next.js package itself.
# pnpm overrides cannot reach these; only a Next.js upgrade can fix them.
# Affects web image only (orchestrator and API are clean). # Affects web image only (orchestrator and API are clean).
# npm was also removed from all production images, eliminating the npm-bundled copy. # npm was also removed from all production images, eliminating the npm-bundled copy.
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.8 and minimatch >= 10.2.1. # To resolve: upgrade Next.js when a release bundles tar >= 7.5.7.
CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3) CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3)
CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4) CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4)
CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7) CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7)
CVE-2026-26960 # HIGH: tar arbitrary file read/write via malicious archive hardlink (needs tar >= 7.5.8)
CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs minimatch >= 10.2.1)
# === OpenBao Go stdlib (waiting on upstream rebuild) === # === OpenBao Go stdlib (waiting on upstream rebuild) ===
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7. # OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.

View File

@@ -85,11 +85,12 @@ install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
## Image Tagging ## Image Tagging
| Condition | Tag | Purpose | | Condition | Tag | Purpose |
| ------------- | -------------------------- | -------------------------- | | ---------------- | -------------------------- | -------------------------- |
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference | | Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
| `main` branch | `latest` | Current latest build | | `main` branch | `latest` | Current production release |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release | | `develop` branch | `dev` | Current development build |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
## Required Secrets ## Required Secrets
@@ -137,5 +138,5 @@ Fails on blockers or critical/high severity security findings.
### Pipeline runs Docker builds on pull requests ### Pipeline runs Docker builds on pull requests
- Docker build steps have `when: branch: [main]` guards - Docker build steps have `when: branch: [main, develop]` guards
- PRs only run quality gates, not Docker builds - PRs only run quality gates, not Docker builds

View File

@@ -15,7 +15,6 @@ when:
- "turbo.json" - "turbo.json"
- "package.json" - "package.json"
- ".woodpecker/api.yml" - ".woodpecker/api.yml"
- ".trivyignore"
variables: variables:
- &node_image "node:24-alpine" - &node_image "node:24-alpine"
@@ -24,13 +23,6 @@ variables:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- &use_deps | - &use_deps |
corepack enable corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup | - &kaniko_setup |
mkdir -p /kaniko/.docker mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
@@ -59,6 +51,17 @@ steps:
depends_on: depends_on:
- install - install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" lint
depends_on:
- prisma-generate
- build-shared
prisma-generate: prisma-generate:
image: *node_image image: *node_image
environment: environment:
@@ -69,27 +72,26 @@ steps:
depends_on: depends_on:
- install - install
lint: build-shared:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo lint --filter=@mosaic/api - pnpm --filter "@mosaic/shared" build
depends_on: depends_on:
- prisma-generate - install
typecheck: typecheck:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo typecheck --filter=@mosaic/api - pnpm --filter "@mosaic/api" typecheck
depends_on: depends_on:
- prisma-generate - prisma-generate
- build-shared
prisma-migrate: prisma-migrate:
image: *node_image image: *node_image
@@ -110,7 +112,7 @@ steps:
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
commands: commands:
- *use_deps - *use_deps
- pnpm --filter "@mosaic/api" exec vitest run --exclude 'src/auth/auth-rls.integration.spec.ts' --exclude 'src/credentials/user-credential.model.spec.ts' --exclude 'src/job-events/job-events.performance.spec.ts' --exclude 'src/knowledge/services/fulltext-search.spec.ts' --exclude 'src/mosaic-telemetry/mosaic-telemetry.module.spec.ts' - pnpm --filter "@mosaic/api" exec vitest run --exclude 'src/auth/auth-rls.integration.spec.ts' --exclude 'src/credentials/user-credential.model.spec.ts' --exclude 'src/job-events/job-events.performance.spec.ts' --exclude 'src/knowledge/services/fulltext-search.spec.ts'
depends_on: depends_on:
- prisma-migrate - prisma-migrate
@@ -121,7 +123,6 @@ steps:
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production" NODE_ENV: "production"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo build --filter=@mosaic/api - pnpm turbo build --filter=@mosaic/api
@@ -150,10 +151,12 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:dev"
fi fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS /kaniko/executor --context . --dockerfile apps/api/Dockerfile $DESTINATIONS
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- build - build
@@ -176,7 +179,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest" SCAN_TAG="latest"
else else
SCAN_TAG="latest" SCAN_TAG="dev"
fi fi
mkdir -p ~/.docker mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -184,7 +187,7 @@ steps:
--ignorefile .trivyignore \ --ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- docker-build-api - docker-build-api
@@ -226,7 +229,7 @@ steps:
} }
link_package "stack-api" link_package "stack-api"
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- security-trivy-api - security-trivy-api

View File

@@ -12,7 +12,7 @@ when:
event: pull_request event: pull_request
variables: variables:
- &node_image "node:24-slim" - &node_image "node:22-slim"
- &install_codex "npm i -g @openai/codex" - &install_codex "npm i -g @openai/codex"
steps: steps:

View File

@@ -30,7 +30,7 @@ steps:
- python -m venv venv - python -m venv venv
- . venv/bin/activate - . venv/bin/activate
- pip install --no-cache-dir --upgrade "pip>=25.3" - pip install --no-cache-dir --upgrade "pip>=25.3"
- pip install --no-cache-dir --extra-index-url https://git.mosaicstack.dev/api/packages/mosaic/pypi/simple/ -e ".[dev]" - pip install --no-cache-dir -e ".[dev]"
- pip install --no-cache-dir bandit pip-audit - pip install --no-cache-dir bandit pip-audit
ruff-check: ruff-check:
@@ -92,10 +92,12 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:dev"
fi fi
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS /kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile $DESTINATIONS
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- ruff-check - ruff-check
@@ -122,7 +124,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest" SCAN_TAG="latest"
else else
SCAN_TAG="latest" SCAN_TAG="dev"
fi fi
mkdir -p ~/.docker mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -130,7 +132,7 @@ steps:
--ignorefile .trivyignore \ --ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- docker-build-coordinator - docker-build-coordinator
@@ -172,7 +174,7 @@ steps:
} }
link_package "stack-coordinator" link_package "stack-coordinator"
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- security-trivy-coordinator - security-trivy-coordinator

View File

@@ -36,10 +36,12 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:dev"
fi fi
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS /kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile $DESTINATIONS
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
docker-build-openbao: docker-build-openbao:
@@ -59,10 +61,12 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:dev"
fi fi
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS /kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile $DESTINATIONS
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
# === Container Security Scans === # === Container Security Scans ===
@@ -83,7 +87,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest" SCAN_TAG="latest"
else else
SCAN_TAG="latest" SCAN_TAG="dev"
fi fi
mkdir -p ~/.docker mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -91,7 +95,7 @@ steps:
--ignorefile .trivyignore \ --ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- docker-build-postgres - docker-build-postgres
@@ -112,7 +116,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest" SCAN_TAG="latest"
else else
SCAN_TAG="latest" SCAN_TAG="dev"
fi fi
mkdir -p ~/.docker mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -120,7 +124,7 @@ steps:
--ignorefile .trivyignore \ --ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- docker-build-openbao - docker-build-openbao
@@ -163,7 +167,7 @@ steps:
link_package "stack-postgres" link_package "stack-postgres"
link_package "stack-openbao" link_package "stack-openbao"
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- security-trivy-postgres - security-trivy-postgres

View File

@@ -15,7 +15,6 @@ when:
- "turbo.json" - "turbo.json"
- "package.json" - "package.json"
- ".woodpecker/orchestrator.yml" - ".woodpecker/orchestrator.yml"
- ".trivyignore"
variables: variables:
- &node_image "node:24-alpine" - &node_image "node:24-alpine"
@@ -24,13 +23,6 @@ variables:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- &use_deps | - &use_deps |
corepack enable corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup | - &kaniko_setup |
mkdir -p /kaniko/.docker mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
@@ -55,10 +47,9 @@ steps:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo lint --filter=@mosaic/orchestrator - pnpm --filter "@mosaic/orchestrator" lint
depends_on: depends_on:
- install - install
@@ -66,10 +57,9 @@ steps:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo typecheck --filter=@mosaic/orchestrator - pnpm --filter "@mosaic/orchestrator" typecheck
depends_on: depends_on:
- install - install
@@ -77,10 +67,9 @@ steps:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo test --filter=@mosaic/orchestrator - pnpm --filter "@mosaic/orchestrator" test
depends_on: depends_on:
- install - install
@@ -91,7 +80,6 @@ steps:
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production" NODE_ENV: "production"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo build --filter=@mosaic/orchestrator - pnpm turbo build --filter=@mosaic/orchestrator
@@ -120,10 +108,12 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:dev"
fi fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS /kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile $DESTINATIONS
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- build - build
@@ -146,7 +136,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest" SCAN_TAG="latest"
else else
SCAN_TAG="latest" SCAN_TAG="dev"
fi fi
mkdir -p ~/.docker mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -154,7 +144,7 @@ steps:
--ignorefile .trivyignore \ --ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- docker-build-orchestrator - docker-build-orchestrator
@@ -196,7 +186,7 @@ steps:
} }
link_package "stack-orchestrator" link_package "stack-orchestrator"
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- security-trivy-orchestrator - security-trivy-orchestrator

View File

@@ -15,7 +15,6 @@ when:
- "turbo.json" - "turbo.json"
- "package.json" - "package.json"
- ".woodpecker/web.yml" - ".woodpecker/web.yml"
- ".trivyignore"
variables: variables:
- &node_image "node:24-alpine" - &node_image "node:24-alpine"
@@ -24,13 +23,6 @@ variables:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- &use_deps | - &use_deps |
corepack enable corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup | - &kaniko_setup |
mkdir -p /kaniko/.docker mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
@@ -51,38 +43,46 @@ steps:
depends_on: depends_on:
- install - install
build-shared:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/shared" build
- pnpm --filter "@mosaic/ui" build
depends_on:
- install
lint: lint:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo lint --filter=@mosaic/web - pnpm --filter "@mosaic/web" lint
depends_on: depends_on:
- install - build-shared
typecheck: typecheck:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo typecheck --filter=@mosaic/web - pnpm --filter "@mosaic/web" typecheck
depends_on: depends_on:
- install - build-shared
test: test:
image: *node_image image: *node_image
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo test --filter=@mosaic/web - pnpm --filter "@mosaic/web" test
depends_on: depends_on:
- install - build-shared
# === Build === # === Build ===
@@ -91,7 +91,6 @@ steps:
environment: environment:
SKIP_ENV_VALIDATION: "true" SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production" NODE_ENV: "production"
<<: *turbo_env
commands: commands:
- *use_deps - *use_deps
- pnpm turbo build --filter=@mosaic/web - pnpm turbo build --filter=@mosaic/web
@@ -120,10 +119,12 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:dev"
fi fi
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS /kaniko/executor --context . --dockerfile apps/web/Dockerfile --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- build - build
@@ -146,7 +147,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest" SCAN_TAG="latest"
else else
SCAN_TAG="latest" SCAN_TAG="dev"
fi fi
mkdir -p ~/.docker mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -154,7 +155,7 @@ steps:
--ignorefile .trivyignore \ --ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- docker-build-web - docker-build-web
@@ -196,7 +197,7 @@ steps:
} }
link_package "stack-web" link_package "stack-web"
when: when:
- branch: [main] - branch: [main, develop]
event: [push, manual, tag] event: [push, manual, tag]
depends_on: depends_on:
- security-trivy-web - security-trivy-web

View File

@@ -1,82 +1,37 @@
# Mosaic Stack — Agent Guidelines # Mosaic Stack — Agent Guidelines
## Load Order > **Any AI model, coding assistant, or framework working in this codebase MUST read and follow `CLAUDE.md` in the project root.**
1. `SOUL.md` (repo identity + behavior invariants) `CLAUDE.md` is the authoritative source for:
2. `~/.config/mosaic/STANDARDS.md` (machine-wide standards rails)
3. `AGENTS.md` (repo-specific overlay)
4. `.mosaic/repo-hooks.sh` (repo lifecycle hooks)
## Runtime Contract - Technology stack and versions
- TypeScript strict mode requirements
- ESLint Quality Rails (error-level enforcement)
- Prettier formatting rules
- Testing requirements (85% coverage, TDD)
- API conventions and database patterns
- Commit format and branch strategy
- PDA-friendly design principles
- This file is authoritative for repo-local operations. ## Quick Rules (Read CLAUDE.md for Details)
- `CLAUDE.md` is a compatibility pointer to `AGENTS.md`.
- Follow universal rails from `~/.config/mosaic/guides/` and `~/.config/mosaic/rails/`.
## Session Lifecycle - **No `any` types** — use `unknown`, generics, or proper types
- **Explicit return types** on all functions
- **Type-only imports** — `import type { Foo }` for types
- **Double quotes**, semicolons, 2-space indent, 100 char width
- **`??` not `||`** for defaults, **`?.`** not `&&` chains
- **All promises** must be awaited or returned
- **85% test coverage** minimum, tests before implementation
```bash ## Updating Conventions
bash scripts/agent/session-start.sh
bash scripts/agent/critical.sh
bash scripts/agent/session-end.sh
```
Optional: If you discover new patterns, gotchas, or conventions while working in this codebase, **update `CLAUDE.md`** — not this file. This file exists solely to redirect agents that look for `AGENTS.md` to the canonical source.
```bash ## Per-App Context
bash scripts/agent/log-limitation.sh "Short Name"
bash scripts/agent/orchestrator-daemon.sh status
bash scripts/agent/orchestrator-events.sh recent --limit 50
```
## Repo Context Each app directory has its own `AGENTS.md` for app-specific patterns:
- Platform: multi-tenant personal assistant stack
- Monorepo: `pnpm` workspaces + Turborepo
- Core apps: `apps/api` (NestJS), `apps/web` (Next.js), orchestrator/coordinator services
- Infrastructure: Docker Compose + PostgreSQL + Valkey + Authentik
## Quick Command Set
```bash
pnpm install
pnpm dev
pnpm test
pnpm lint
pnpm build
```
## Versioning Protocol (HARD GATE)
**This project is ALPHA. All versions MUST be `0.0.x`.**
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
- Every milestone bump increments the patch: `0.0.20``0.0.21``0.0.22`, etc.
- ALL package.json files in the monorepo MUST stay in sync at the same version.
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
- The script rejects any version >= `0.1.0`.
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
## Standards and Quality
- Enforce strict typing and no unsafe shortcuts.
- Keep lint/typecheck/tests green before completion.
- Prefer small, focused commits and clear change descriptions.
## App-Specific Overlays
- `apps/api/AGENTS.md` - `apps/api/AGENTS.md`
- `apps/web/AGENTS.md` - `apps/web/AGENTS.md`
- `apps/coordinator/AGENTS.md` - `apps/coordinator/AGENTS.md`
- `apps/orchestrator/AGENTS.md` - `apps/orchestrator/AGENTS.md`
## Additional Guidance
- Orchestrator guidance: `docs/claude/orchestrator.md`
- Security remediation context: `docs/reports/codebase-review-2026-02-05/01-security-review.md`
- Code quality context: `docs/reports/codebase-review-2026-02-05/02-code-quality-review.md`
- QA context: `docs/reports/codebase-review-2026-02-05/03-qa-test-coverage.md`

479
CLAUDE.md
View File

@@ -1,10 +1,477 @@
# CLAUDE Compatibility Pointer **Multi-tenant personal assistant platform with PostgreSQL backend, Authentik SSO, and MoltBot
integration.**
This file exists so Claude Code sessions load Mosaic standards. ## Conditional Documentation Loading
## MANDATORY — Read Before Any Response | When working on... | Load this guide |
| ---------------------------------------- | ------------------------------------------------------------------- |
| Orchestrating autonomous task completion | `docs/claude/orchestrator.md` |
| Security remediation (review findings) | `docs/reports/codebase-review-2026-02-05/01-security-review.md` |
| Code quality fixes | `docs/reports/codebase-review-2026-02-05/02-code-quality-review.md` |
| Test coverage gaps | `docs/reports/codebase-review-2026-02-05/03-qa-test-coverage.md` |
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`. ## Platform Templates
That file is the universal agent configuration. Do NOT respond until you have loaded it. Bootstrap templates are at `docs/templates/`. See `docs/templates/README.md` for usage.
Then read the project-local `AGENTS.md` in this repository for project-specific guidance.
## Project Overview
Mosaic Stack is a standalone platform that provides:
- Multi-user workspaces with team sharing
- Task, event, and project management
- Gantt charts and Kanban boards
- MoltBot integration via plugins (stock MoltBot + mosaic-plugin-\*)
- PDA-friendly design throughout
**Repository:** git.mosaicstack.dev/mosaic/stack
**Versioning:** Start at 0.0.1, MVP = 0.1.0
## Technology Stack
| Layer | Technology |
| ---------- | -------------------------------------------- |
| Frontend | Next.js 16 + React + TailwindCSS + Shadcn/ui |
| Backend | NestJS + Prisma ORM |
| Database | PostgreSQL 17 + pgvector |
| Cache | Valkey (Redis-compatible) |
| Auth | Authentik (OIDC) |
| AI | Ollama (configurable: local or remote) |
| Messaging | MoltBot (stock + Mosaic plugins) |
| Real-time | WebSockets (Socket.io) |
| Monorepo | pnpm workspaces + TurboRepo |
| Testing | Vitest + Playwright |
| Deployment | Docker + docker-compose |
## Repository Structure
mosaic-stack/
├── apps/
│ ├── api/ # mosaic-api (NestJS)
│ │ ├── src/
│ │ │ ├── auth/ # Authentik OIDC
│ │ │ ├── tasks/ # Task management
│ │ │ ├── events/ # Calendar/events
│ │ │ ├── projects/ # Project management
│ │ │ ├── brain/ # MoltBot integration
│ │ │ └── activity/ # Activity logging
│ │ ├── prisma/
│ │ │ └── schema.prisma
│ │ └── Dockerfile
│ └── web/ # mosaic-web (Next.js 16)
│ ├── app/
│ ├── components/
│ └── Dockerfile
├── packages/
│ ├── shared/ # Shared types, utilities
│ ├── ui/ # Shared UI components
│ └── config/ # Shared configuration
├── plugins/
│ ├── mosaic-plugin-brain/ # MoltBot skill: API queries
│ ├── mosaic-plugin-calendar/ # MoltBot skill: Calendar
│ ├── mosaic-plugin-tasks/ # MoltBot skill: Tasks
│ └── mosaic-plugin-gantt/ # MoltBot skill: Gantt
├── docker/
│ ├── docker-compose.yml # Turnkey deployment
│ └── init-scripts/ # PostgreSQL init
├── docs/
│ ├── SETUP.md
│ ├── CONFIGURATION.md
│ └── DESIGN-PRINCIPLES.md
├── .env.example
├── turbo.json
├── pnpm-workspace.yaml
└── README.md
## Development Workflow
### Branch Strategy
- `main` — stable releases only
- `develop` — active development (default working branch)
- `feature/*` — feature branches from develop
- `fix/*` — bug fix branches
### Starting Work
````bash
git checkout develop
git pull --rebase
pnpm install
Running Locally
# Start all services (Docker)
docker compose up -d
# Or run individually for development
pnpm dev # All apps
pnpm dev:api # API only
pnpm dev:web # Web only
Testing
pnpm test # Run all tests
pnpm test:api # API tests only
pnpm test:web # Web tests only
pnpm test:e2e # Playwright E2E
Building
pnpm build # Build all
pnpm build:api # Build API
pnpm build:web # Build Web
Design Principles (NON-NEGOTIABLE)
PDA-Friendly Language
NEVER use demanding language. This is critical.
┌─────────────┬──────────────────────┐
│ ❌ NEVER │ ✅ ALWAYS │
├─────────────┼──────────────────────┤
│ OVERDUE │ Target passed │
├─────────────┼──────────────────────┤
│ URGENT │ Approaching target │
├─────────────┼──────────────────────┤
│ MUST DO │ Scheduled for │
├─────────────┼──────────────────────┤
│ CRITICAL │ High priority │
├─────────────┼──────────────────────┤
│ YOU NEED TO │ Consider / Option to │
├─────────────┼──────────────────────┤
│ REQUIRED │ Recommended │
└─────────────┴──────────────────────┘
Visual Indicators
Use status indicators consistently:
- 🟢 On track / Active
- 🔵 Upcoming / Scheduled
- ⏸️ Paused / On hold
- 💤 Dormant / Inactive
- ⚪ Not started
Display Principles
1. 10-second scannability — Key info visible immediately
2. Visual chunking — Clear sections with headers
3. Single-line items — Compact, scannable lists
4. Date grouping — Today, Tomorrow, This Week headers
5. Progressive disclosure — Details on click, not upfront
6. Calm colors — No aggressive reds for status
Reference
See docs/DESIGN-PRINCIPLES.md for complete guidelines.
For original patterns, see: jarvis-brain/docs/DESIGN-PRINCIPLES.md
API Conventions
Endpoints
GET /api/{resource} # List (with pagination, filters)
GET /api/{resource}/:id # Get single
POST /api/{resource} # Create
PATCH /api/{resource}/:id # Update
DELETE /api/{resource}/:id # Delete
Response Format
// Success
{
data: T | T[],
meta?: { total, page, limit }
}
// Error
{
error: {
code: string,
message: string,
details?: any
}
}
Brain Query API
POST /api/brain/query
{
query: "what's on my calendar",
context?: { view: "dashboard", workspace_id: "..." }
}
Database Conventions
Multi-Tenant (RLS)
All workspace-scoped tables use Row-Level Security:
- Always include workspace_id in queries
- RLS policies enforce isolation
- Set session context for current user
Prisma Commands
pnpm prisma:generate # Generate client
pnpm prisma:migrate # Run migrations
pnpm prisma:studio # Open Prisma Studio
pnpm prisma:seed # Seed development data
MoltBot Plugin Development
Plugins live in plugins/mosaic-plugin-*/ and follow MoltBot skill format:
# plugins/mosaic-plugin-brain/SKILL.md
---
name: mosaic-plugin-brain
description: Query Mosaic Stack for tasks, events, projects
version: 0.0.1
triggers:
- "what's on my calendar"
- "show my tasks"
- "morning briefing"
tools:
- mosaic_api
---
# Plugin instructions here...
Key principle: MoltBot remains stock. All customization via plugins only.
Environment Variables
See .env.example for all variables. Key ones:
# Database
DATABASE_URL=postgresql://mosaic:password@localhost:5432/mosaic
# Auth
AUTHENTIK_URL=https://auth.example.com
AUTHENTIK_CLIENT_ID=mosaic-stack
AUTHENTIK_CLIENT_SECRET=...
# Ollama
OLLAMA_MODE=local|remote
OLLAMA_ENDPOINT=http://localhost:11434
# MoltBot
MOSAIC_API_TOKEN=...
Issue Tracking
Issues are tracked at: https://git.mosaicstack.dev/mosaic/stack/issues
Labels
- Priority: p0 (critical), p1 (high), p2 (medium), p3 (low)
- Type: api, web, database, auth, plugin, ai, devops, docs, migration, security, testing,
performance, setup
Milestones
- M1-Foundation (0.0.x)
- M2-MultiTenant (0.0.x)
- M3-Features (0.0.x)
- M4-MoltBot (0.0.x)
- M5-Migration (0.1.0 MVP)
Commit Format
<type>(#issue): Brief description
Detailed explanation if needed.
Fixes #123
Types: feat, fix, docs, test, refactor, chore
Test-Driven Development (TDD) - REQUIRED
**All code must follow TDD principles. This is non-negotiable.**
TDD Workflow (Red-Green-Refactor)
1. **RED** — Write a failing test first
- Write the test for new functionality BEFORE writing any implementation code
- Run the test to verify it fails (proves the test works)
- Commit message: `test(#issue): add test for [feature]`
2. **GREEN** — Write minimal code to make the test pass
- Implement only enough code to pass the test
- Run tests to verify they pass
- Commit message: `feat(#issue): implement [feature]`
3. **REFACTOR** — Clean up the code while keeping tests green
- Improve code quality, remove duplication, enhance readability
- Ensure all tests still pass after refactoring
- Commit message: `refactor(#issue): improve [component]`
Testing Requirements
- **Minimum 85% code coverage** for all new code
- **Write tests BEFORE implementation** — no exceptions
- Test files must be co-located with source files:
- `feature.service.ts` → `feature.service.spec.ts`
- `component.tsx` → `component.test.tsx`
- All tests must pass before creating a PR
- Use descriptive test names: `it("should return user when valid token provided")`
- Group related tests with `describe()` blocks
- Mock external dependencies (database, APIs, file system)
Test Types
- **Unit Tests** — Test individual functions/methods in isolation
- **Integration Tests** — Test module interactions (e.g., service + database)
- **E2E Tests** — Test complete user workflows with Playwright
Running Tests
```bash
pnpm test # Run all tests
pnpm test:watch # Watch mode for active development
pnpm test:coverage # Generate coverage report
pnpm test:api # API tests only
pnpm test:web # Web tests only
pnpm test:e2e # Playwright E2E tests
````
Coverage Verification
After implementing a feature, verify coverage meets requirements:
```bash
pnpm test:coverage
# Check the coverage report in coverage/index.html
# Ensure your files show ≥85% coverage
```
TDD Anti-Patterns to Avoid
❌ Writing implementation code before tests
❌ Writing tests after implementation is complete
❌ Skipping tests for "simple" code
❌ Testing implementation details instead of behavior
❌ Writing tests that don't fail when they should
❌ Committing code with failing tests
Quality Rails - Mechanical Code Quality Enforcement
**Status:** ACTIVE (2026-01-30) - Strict enforcement enabled ✅
Quality Rails provides mechanical enforcement of code quality standards through pre-commit hooks
and CI/CD pipelines. See `docs/quality-rails-status.md` for full details.
What's Enforced (NOW ACTIVE):
- ✅ **Type Safety** - Blocks explicit `any` types (@typescript-eslint/no-explicit-any: error)
- ✅ **Return Types** - Requires explicit return types on exported functions
- ✅ **Security** - Detects SQL injection, XSS, unsafe regex (eslint-plugin-security)
- ✅ **Promise Safety** - Blocks floating promises and misused promises
- ✅ **Code Formatting** - Auto-formats with Prettier on commit
- ✅ **Build Verification** - Type-checks before allowing commit
- ✅ **Secret Scanning** - Blocks hardcoded passwords/API keys (git-secrets)
Current Status:
- ✅ **Pre-commit hooks**: ACTIVE - Blocks commits with violations
- ✅ **Strict enforcement**: ENABLED - Package-level enforcement
- 🟡 **CI/CD pipeline**: Ready (.woodpecker.yml created, not yet configured)
How It Works:
**Package-Level Enforcement** - If you touch ANY file in a package with violations,
you must fix ALL violations in that package before committing. This forces incremental
cleanup while preventing new violations.
Example:
- Edit `apps/api/src/tasks/tasks.service.ts`
- Pre-commit hook runs lint on ENTIRE `@mosaic/api` package
- If `@mosaic/api` has violations → Commit BLOCKED
- Fix all violations in `@mosaic/api` → Commit allowed
Next Steps:
1. Fix violations package-by-package as you work in them
2. Priority: Fix explicit `any` types and type safety issues first
3. Configure Woodpecker CI to run quality gates on all PRs
Why This Matters:
Based on validation of 50 real production issues, Quality Rails mechanically prevents ~70%
of quality issues including:
- Hardcoded passwords
- Type safety violations
- SQL injection vulnerabilities
- Build failures
- Test coverage gaps
**Mechanical enforcement works. Process compliance doesn't.**
See `docs/quality-rails-status.md` for detailed roadmap and violation breakdown.
Example TDD Session
```bash
# 1. RED - Write failing test
# Edit: feature.service.spec.ts
# Add test for getUserById()
pnpm test:watch # Watch it fail
git add feature.service.spec.ts
git commit -m "test(#42): add test for getUserById"
# 2. GREEN - Implement minimal code
# Edit: feature.service.ts
# Add getUserById() method
pnpm test:watch # Watch it pass
git add feature.service.ts
git commit -m "feat(#42): implement getUserById"
# 3. REFACTOR - Improve code quality
# Edit: feature.service.ts
# Extract helper, improve naming
pnpm test:watch # Ensure still passing
git add feature.service.ts
git commit -m "refactor(#42): extract user mapping logic"
```
Docker Deployment
Turnkey (includes everything)
docker compose up -d
Customized (external services)
Create docker-compose.override.yml to:
- Point to external PostgreSQL/Valkey/Ollama
- Disable bundled services
See docs/DOCKER.md for details.
Key Documentation
┌───────────────────────────┬───────────────────────┐
│ Document │ Purpose │
├───────────────────────────┼───────────────────────┤
│ docs/SETUP.md │ Installation guide │
├───────────────────────────┼───────────────────────┤
│ docs/CONFIGURATION.md │ All config options │
├───────────────────────────┼───────────────────────┤
│ docs/DESIGN-PRINCIPLES.md │ PDA-friendly patterns │
├───────────────────────────┼───────────────────────┤
│ docs/DOCKER.md │ Docker deployment │
├───────────────────────────┼───────────────────────┤
│ docs/API.md │ API documentation │
└───────────────────────────┴───────────────────────┘
Related Repositories
┌──────────────┬──────────────────────────────────────────────┐
│ Repo │ Purpose │
├──────────────┼──────────────────────────────────────────────┤
│ jarvis-brain │ Original JSON-based brain (migration source) │
├──────────────┼──────────────────────────────────────────────┤
│ MoltBot │ Stock messaging gateway │
└──────────────┴──────────────────────────────────────────────┘
---
Mosaic Stack v0.0.x — Building the future of personal assistants.

View File

@@ -1,4 +1,4 @@
.PHONY: help install dev build test docker-up docker-down docker-logs docker-ps docker-build docker-restart docker-test speech-up speech-down speech-logs clean matrix-up matrix-down matrix-logs matrix-setup-bot .PHONY: help install dev build test docker-up docker-down docker-logs docker-ps docker-build docker-restart docker-test clean
# Default target # Default target
help: help:
@@ -24,17 +24,6 @@ help:
@echo " make docker-test Run Docker smoke test" @echo " make docker-test Run Docker smoke test"
@echo " make docker-test-traefik Run Traefik integration tests" @echo " make docker-test-traefik Run Traefik integration tests"
@echo "" @echo ""
@echo "Speech Services:"
@echo " make speech-up Start speech services (STT + TTS)"
@echo " make speech-down Stop speech services"
@echo " make speech-logs View speech service logs"
@echo ""
@echo "Matrix Dev Environment:"
@echo " make matrix-up Start Matrix services (Synapse + Element)"
@echo " make matrix-down Stop Matrix services"
@echo " make matrix-logs View Matrix service logs"
@echo " make matrix-setup-bot Create bot account and get access token"
@echo ""
@echo "Database:" @echo "Database:"
@echo " make db-migrate Run database migrations" @echo " make db-migrate Run database migrations"
@echo " make db-seed Seed development data" @echo " make db-seed Seed development data"
@@ -96,29 +85,6 @@ docker-test:
docker-test-traefik: docker-test-traefik:
./tests/integration/docker/traefik.test.sh all ./tests/integration/docker/traefik.test.sh all
# Speech services
speech-up:
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d speaches kokoro-tts
speech-down:
docker compose -f docker-compose.yml -f docker-compose.speech.yml down --remove-orphans
speech-logs:
docker compose -f docker-compose.yml -f docker-compose.speech.yml logs -f speaches kokoro-tts
# Matrix Dev Environment
matrix-up:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up -d
matrix-down:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml down
matrix-logs:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml logs -f synapse element-web
matrix-setup-bot:
docker/matrix/scripts/setup-bot.sh
# Database operations # Database operations
db-migrate: db-migrate:
cd apps/api && pnpm prisma:migrate cd apps/api && pnpm prisma:migrate

View File

@@ -19,20 +19,19 @@ Mosaic Stack is a modern, PDA-friendly platform designed to help users manage th
## Technology Stack ## Technology Stack
| Layer | Technology | | Layer | Technology |
| -------------- | ---------------------------------------------- | | -------------- | -------------------------------------------- |
| **Frontend** | Next.js 16 + React + TailwindCSS + Shadcn/ui | | **Frontend** | Next.js 16 + React + TailwindCSS + Shadcn/ui |
| **Backend** | NestJS + Prisma ORM | | **Backend** | NestJS + Prisma ORM |
| **Database** | PostgreSQL 17 + pgvector | | **Database** | PostgreSQL 17 + pgvector |
| **Cache** | Valkey (Redis-compatible) | | **Cache** | Valkey (Redis-compatible) |
| **Auth** | Authentik (OIDC) via BetterAuth | | **Auth** | Authentik (OIDC) via BetterAuth |
| **AI** | Ollama (local or remote) | | **AI** | Ollama (local or remote) |
| **Messaging** | MoltBot (stock + plugins) | | **Messaging** | MoltBot (stock + plugins) |
| **Real-time** | WebSockets (Socket.io) | | **Real-time** | WebSockets (Socket.io) |
| **Speech** | Speaches (STT) + Kokoro/Chatterbox/Piper (TTS) | | **Monorepo** | pnpm workspaces + TurboRepo |
| **Monorepo** | pnpm workspaces + TurboRepo | | **Testing** | Vitest + Playwright |
| **Testing** | Vitest + Playwright | | **Deployment** | Docker + docker-compose |
| **Deployment** | Docker + docker-compose |
## Quick Start ## Quick Start
@@ -90,7 +89,7 @@ docker compose down
If you prefer manual installation, you'll need: If you prefer manual installation, you'll need:
- **Docker mode:** Docker 24+ and Docker Compose - **Docker mode:** Docker 24+ and Docker Compose
- **Native mode:** Node.js 24+, pnpm 10+, PostgreSQL 17+ - **Native mode:** Node.js 22+, pnpm 10+, PostgreSQL 17+
The installer handles these automatically. The installer handles these automatically.
@@ -232,7 +231,7 @@ docker compose -f docker-compose.openbao.yml up -d
sleep 30 # Wait for auto-initialization sleep 30 # Wait for auto-initialization
# 5. Deploy swarm stack # 5. Deploy swarm stack
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
# 6. Check deployment status # 6. Check deployment status
docker stack services mosaic docker stack services mosaic
@@ -357,29 +356,6 @@ Mosaic Stack includes a sophisticated agent orchestration system for autonomous
See [Agent Orchestration Design](docs/design/agent-orchestration.md) for architecture details. See [Agent Orchestration Design](docs/design/agent-orchestration.md) for architecture details.
## Speech Services
Mosaic Stack includes integrated speech-to-text (STT) and text-to-speech (TTS) capabilities through a modular provider architecture. Each component is optional and independently configurable.
- **Speech-to-Text** - Transcribe audio files and real-time audio streams using Whisper (via Speaches)
- **Text-to-Speech** - Synthesize speech with 54+ voices across 8 languages (via Kokoro, CPU-based)
- **Premium Voice Cloning** - Clone voices from audio samples with emotion control (via Chatterbox, GPU)
- **Fallback TTS** - Ultra-lightweight CPU fallback for low-resource environments (via Piper/OpenedAI Speech)
- **WebSocket Streaming** - Real-time streaming transcription via Socket.IO `/speech` namespace
- **Automatic Fallback** - TTS tier system with graceful degradation (premium -> default -> fallback)
**Quick Start:**
```bash
# Start speech services alongside core stack
make speech-up
# Or with Docker Compose directly
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d
```
See [Speech Services Documentation](docs/SPEECH.md) for architecture details, API reference, provider configuration, and deployment options.
## Current Implementation Status ## Current Implementation Status
### ✅ Completed (v0.0.1-0.0.6) ### ✅ Completed (v0.0.1-0.0.6)
@@ -526,9 +502,10 @@ KNOWLEDGE_CACHE_TTL=300 # 5 minutes
### Branch Strategy ### Branch Strategy
- `main`Trunk branch (all development merges here) - `main`Stable releases only
- `feature/*` — Feature branches from main - `develop` — Active development (default working branch)
- `fix/*`Bug fix branches from main - `feature/*`Feature branches from develop
- `fix/*` — Bug fix branches
### Running Locally ### Running Locally
@@ -738,7 +715,7 @@ See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for
4. Run tests: `pnpm test` 4. Run tests: `pnpm test`
5. Build: `pnpm build` 5. Build: `pnpm build`
6. Commit with conventional format: `feat(#issue): Description` 6. Commit with conventional format: `feat(#issue): Description`
7. Push and create a pull request to `main` 7. Push and create a pull request to `develop`
### Commit Format ### Commit Format

20
SOUL.md
View File

@@ -1,20 +0,0 @@
# Mosaic Stack Soul
You are Jarvis for the Mosaic Stack repository, running on the current agent runtime.
## Behavioral Invariants
- Identity first: answer identity prompts as Jarvis for this repository.
- Implementation detail second: runtime (Codex/Claude/OpenCode/etc.) is secondary metadata.
- Be proactive: surface risks, blockers, and next actions without waiting.
- Be calm and clear: keep responses concise, chunked, and PDA-friendly.
- Respect canonical sources:
- Repo operations and conventions: `AGENTS.md`
- Machine-wide rails: `~/.config/mosaic/STANDARDS.md`
- Repo lifecycle hooks: `.mosaic/repo-hooks.sh`
## Guardrails
- Do not claim completion without verification evidence.
- Do not bypass lint/type/test quality gates.
- Prefer explicit assumptions and concrete file/command references.

View File

@@ -4,22 +4,15 @@
## Patterns ## Patterns
- **Config validation pattern**: Config files use exported validation functions + typed getter functions (not class-validator). See `auth.config.ts`, `federation.config.ts`, `speech/speech.config.ts`. Pattern: export `isXEnabled()`, `validateXConfig()`, and `getXConfig()` functions. <!-- Add module-specific patterns as you discover them -->
- **Config registerAs**: `speech.config.ts` also exports a `registerAs("speech", ...)` factory for NestJS ConfigModule namespaced injection. Use `ConfigModule.forFeature(speechConfig)` in module imports and access via `this.config.get<string>('speech.stt.baseUrl')`.
- **Conditional config validation**: When a service has an enabled flag (e.g., `STT_ENABLED`), URL/connection vars are only required when enabled. Validation throws with a helpful message suggesting how to disable.
- **Boolean env parsing**: Use `value === "true" || value === "1"` pattern. No default-true -- all services default to disabled when env var is unset.
## Gotchas ## Gotchas
- **Prisma client must be generated** before `tsc --noEmit` will pass. Run `pnpm prisma:generate` first. Pre-existing type errors from Prisma are expected in worktrees without generated client. <!-- Add things that trip up agents in this module -->
- **Pre-commit hooks**: lint-staged runs on staged files. If other packages' files are staged, their lint must pass too. Only stage files you intend to commit.
- **vitest runs all test files**: Even when targeting a specific test file, vitest loads all spec files. Many will fail if Prisma client isn't generated -- this is expected. Check only your target file's pass/fail status.
## Key Files ## Key Files
| File | Purpose | | File | Purpose |
| ------------------------------------- | ---------------------------------------------------------------------- | | ---- | ------- |
| `src/speech/speech.config.ts` | Speech services env var validation and typed config (STT, TTS, limits) |
| `src/speech/speech.config.spec.ts` | Unit tests for speech config validation (51 tests) | <!-- Add important files in this directory -->
| `src/auth/auth.config.ts` | Auth/OIDC config validation (reference pattern) |
| `src/federation/federation.config.ts` | Federation config validation (reference pattern) |

View File

@@ -1,7 +1,8 @@
# syntax=docker/dockerfile:1
# Enable BuildKit features for cache mounts
# Base image for all stages # Base image for all stages
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons FROM node:24-alpine AS base
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
FROM node:24-slim AS base
# Install pnpm globally # Install pnpm globally
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
@@ -18,24 +19,15 @@ COPY turbo.json ./
# ====================== # ======================
FROM base AS deps FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation)
# and OpenSSL for Prisma engine detection
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy all package.json files for workspace resolution # Copy all package.json files for workspace resolution
COPY packages/shared/package.json ./packages/shared/ COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/ COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/ COPY apps/api/package.json ./apps/api/
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI) # Install dependencies with pnpm store cache
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
# scripts or fail to find prebuilt binaries for this Node.js version pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile \
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
&& npx node-gyp rebuild 2>&1 || true
# ====================== # ======================
# Builder stage # Builder stage
@@ -61,20 +53,16 @@ RUN pnpm turbo build --filter=@mosaic/api --force
# ====================== # ======================
# Production stage # Production stage
# ====================== # ======================
FROM node:24-slim AS production FROM node:24-alpine AS production
# Install dumb-init for proper signal handling (static binary from GitHub, # Remove npm (unused in production — we use pnpm) to reduce attack surface
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors) RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot) # Install dumb-init for proper signal handling
# - openssl: Prisma engine detection requires libssl RUN apk add --no-cache dumb-init
# - No build tools needed here — native addons are compiled in the deps stage
RUN apt-get update && apt-get install -y --no-install-recommends openssl \ # Create non-root user
&& rm -rf /var/lib/apt/lists/* \ RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
WORKDIR /app WORKDIR /app

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/api", "name": "@mosaic/api",
"version": "0.0.20", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
@@ -27,7 +27,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.72.1", "@anthropic-ai/sdk": "^0.72.1",
"@mosaic/shared": "workspace:*", "@mosaic/shared": "workspace:*",
"@mosaicstack/telemetry-client": "^0.1.1", "@mosaicstack/telemetry-client": "^0.1.0",
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.12", "@nestjs/common": "^11.1.12",
@@ -52,7 +52,6 @@
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.17", "better-auth": "^1.4.17",
"bullmq": "^5.67.2", "bullmq": "^5.67.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -66,8 +65,6 @@
"marked": "^17.0.1", "marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.3", "marked-gfm-heading-id": "^4.1.3",
"marked-highlight": "^2.2.3", "marked-highlight": "^2.2.3",
"matrix-bot-sdk": "^0.8.0",
"node-pty": "^1.0.0",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"openai": "^6.17.0", "openai": "^6.17.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -86,7 +83,6 @@
"@swc/core": "^1.10.18", "@swc/core": "^1.10.18",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/highlight.js": "^10.1.0", "@types/highlight.js": "^10.1.0",

View File

@@ -1,197 +0,0 @@
-- RecreateEnum: FormalityLevel was dropped in 20260129235248_add_link_storage_fields
CREATE TYPE "FormalityLevel" AS ENUM ('VERY_CASUAL', 'CASUAL', 'NEUTRAL', 'FORMAL', 'VERY_FORMAL');
-- RecreateTable: personalities was dropped in 20260129235248_add_link_storage_fields
-- Recreated with current schema (display_name, system_prompt, temperature, etc.)
CREATE TABLE "personalities" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"description" TEXT,
"system_prompt" TEXT NOT NULL,
"temperature" DOUBLE PRECISION,
"max_tokens" INTEGER,
"llm_provider_instance_id" UUID,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "personalities_pkey" PRIMARY KEY ("id")
);
-- CreateIndex: personalities
CREATE UNIQUE INDEX "personalities_id_workspace_id_key" ON "personalities"("id", "workspace_id");
CREATE UNIQUE INDEX "personalities_workspace_id_name_key" ON "personalities"("workspace_id", "name");
CREATE INDEX "personalities_workspace_id_idx" ON "personalities"("workspace_id");
CREATE INDEX "personalities_workspace_id_is_default_idx" ON "personalities"("workspace_id", "is_default");
CREATE INDEX "personalities_workspace_id_is_enabled_idx" ON "personalities"("workspace_id", "is_enabled");
CREATE INDEX "personalities_llm_provider_instance_id_idx" ON "personalities"("llm_provider_instance_id");
-- AddForeignKey: personalities
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_llm_provider_instance_id_fkey" FOREIGN KEY ("llm_provider_instance_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "cron_schedules" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"expression" TEXT NOT NULL,
"command" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"last_run" TIMESTAMPTZ,
"next_run" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "cron_schedules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspace_llm_settings" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"default_llm_provider_id" UUID,
"default_personality_id" UUID,
"settings" JSONB DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "workspace_llm_settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quality_gates" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"command" TEXT,
"expected_output" TEXT,
"is_regex" BOOLEAN NOT NULL DEFAULT false,
"required" BOOLEAN NOT NULL DEFAULT true,
"order" INTEGER NOT NULL DEFAULT 0,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "quality_gates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_rejections" (
"id" UUID NOT NULL,
"task_id" TEXT NOT NULL,
"workspace_id" TEXT NOT NULL,
"agent_id" TEXT NOT NULL,
"attempt_count" INTEGER NOT NULL,
"failures" JSONB NOT NULL,
"original_task" TEXT NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"rejected_at" TIMESTAMPTZ NOT NULL,
"escalated" BOOLEAN NOT NULL DEFAULT false,
"manual_review" BOOLEAN NOT NULL DEFAULT false,
"resolved_at" TIMESTAMPTZ,
"resolution" TEXT,
CONSTRAINT "task_rejections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "token_budgets" (
"id" UUID NOT NULL,
"task_id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"allocated_tokens" INTEGER NOT NULL,
"estimated_complexity" TEXT NOT NULL,
"input_tokens_used" INTEGER NOT NULL DEFAULT 0,
"output_tokens_used" INTEGER NOT NULL DEFAULT 0,
"total_tokens_used" INTEGER NOT NULL DEFAULT 0,
"estimated_cost" DECIMAL(10,6),
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_updated_at" TIMESTAMPTZ NOT NULL,
"completed_at" TIMESTAMPTZ,
"budget_utilization" DOUBLE PRECISION,
"suspicious_pattern" BOOLEAN NOT NULL DEFAULT false,
"suspicious_reason" TEXT,
CONSTRAINT "token_budgets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "llm_usage_logs" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"provider" VARCHAR(50) NOT NULL,
"model" VARCHAR(100) NOT NULL,
"provider_instance_id" UUID,
"prompt_tokens" INTEGER NOT NULL DEFAULT 0,
"completion_tokens" INTEGER NOT NULL DEFAULT 0,
"total_tokens" INTEGER NOT NULL DEFAULT 0,
"cost_cents" DOUBLE PRECISION,
"task_type" VARCHAR(50),
"conversation_id" UUID,
"duration_ms" INTEGER,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "llm_usage_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex: cron_schedules
CREATE INDEX "cron_schedules_workspace_id_idx" ON "cron_schedules"("workspace_id");
CREATE INDEX "cron_schedules_workspace_id_enabled_idx" ON "cron_schedules"("workspace_id", "enabled");
CREATE INDEX "cron_schedules_next_run_idx" ON "cron_schedules"("next_run");
-- CreateIndex: workspace_llm_settings
CREATE UNIQUE INDEX "workspace_llm_settings_workspace_id_key" ON "workspace_llm_settings"("workspace_id");
CREATE INDEX "workspace_llm_settings_workspace_id_idx" ON "workspace_llm_settings"("workspace_id");
CREATE INDEX "workspace_llm_settings_default_llm_provider_id_idx" ON "workspace_llm_settings"("default_llm_provider_id");
CREATE INDEX "workspace_llm_settings_default_personality_id_idx" ON "workspace_llm_settings"("default_personality_id");
-- CreateIndex: quality_gates
CREATE UNIQUE INDEX "quality_gates_workspace_id_name_key" ON "quality_gates"("workspace_id", "name");
CREATE INDEX "quality_gates_workspace_id_idx" ON "quality_gates"("workspace_id");
CREATE INDEX "quality_gates_workspace_id_is_enabled_idx" ON "quality_gates"("workspace_id", "is_enabled");
-- CreateIndex: task_rejections
CREATE INDEX "task_rejections_task_id_idx" ON "task_rejections"("task_id");
CREATE INDEX "task_rejections_workspace_id_idx" ON "task_rejections"("workspace_id");
CREATE INDEX "task_rejections_agent_id_idx" ON "task_rejections"("agent_id");
CREATE INDEX "task_rejections_escalated_idx" ON "task_rejections"("escalated");
CREATE INDEX "task_rejections_manual_review_idx" ON "task_rejections"("manual_review");
-- CreateIndex: token_budgets
CREATE UNIQUE INDEX "token_budgets_task_id_key" ON "token_budgets"("task_id");
CREATE INDEX "token_budgets_task_id_idx" ON "token_budgets"("task_id");
CREATE INDEX "token_budgets_workspace_id_idx" ON "token_budgets"("workspace_id");
CREATE INDEX "token_budgets_suspicious_pattern_idx" ON "token_budgets"("suspicious_pattern");
-- CreateIndex: llm_usage_logs
CREATE INDEX "llm_usage_logs_workspace_id_idx" ON "llm_usage_logs"("workspace_id");
CREATE INDEX "llm_usage_logs_workspace_id_created_at_idx" ON "llm_usage_logs"("workspace_id", "created_at");
CREATE INDEX "llm_usage_logs_user_id_idx" ON "llm_usage_logs"("user_id");
CREATE INDEX "llm_usage_logs_provider_idx" ON "llm_usage_logs"("provider");
CREATE INDEX "llm_usage_logs_model_idx" ON "llm_usage_logs"("model");
CREATE INDEX "llm_usage_logs_provider_instance_id_idx" ON "llm_usage_logs"("provider_instance_id");
CREATE INDEX "llm_usage_logs_task_type_idx" ON "llm_usage_logs"("task_type");
CREATE INDEX "llm_usage_logs_conversation_id_idx" ON "llm_usage_logs"("conversation_id");
-- AddForeignKey: cron_schedules
ALTER TABLE "cron_schedules" ADD CONSTRAINT "cron_schedules_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: workspace_llm_settings
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_default_llm_provider_id_fkey" FOREIGN KEY ("default_llm_provider_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_default_personality_id_fkey" FOREIGN KEY ("default_personality_id") REFERENCES "personalities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: quality_gates
ALTER TABLE "quality_gates" ADD CONSTRAINT "quality_gates_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: llm_usage_logs
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_provider_instance_id_fkey" FOREIGN KEY ("provider_instance_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT;

View File

@@ -1,49 +0,0 @@
-- Fix schema drift: tables, indexes, and constraints defined in schema.prisma
-- but never created (or dropped and never recreated) by prior migrations.
-- ============================================
-- CreateTable: instances (Federation module)
-- Never created in any prior migration
-- ============================================
CREATE TABLE "instances" (
"id" UUID NOT NULL,
"instance_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"public_key" TEXT NOT NULL,
"private_key" TEXT NOT NULL,
"capabilities" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "instances_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "instances_instance_id_key" ON "instances"("instance_id");
-- ============================================
-- Recreate dropped unique index on knowledge_links
-- Created in 20260129220645_add_knowledge_module, dropped in
-- 20260129235248_add_link_storage_fields, never recreated.
-- ============================================
CREATE UNIQUE INDEX "knowledge_links_source_id_target_id_key" ON "knowledge_links"("source_id", "target_id");
-- ============================================
-- Missing @@unique([id, workspaceId]) composite indexes
-- Defined in schema.prisma but never created in migrations.
-- (agent_tasks and runner_jobs already have these.)
-- ============================================
CREATE UNIQUE INDEX "tasks_id_workspace_id_key" ON "tasks"("id", "workspace_id");
CREATE UNIQUE INDEX "events_id_workspace_id_key" ON "events"("id", "workspace_id");
CREATE UNIQUE INDEX "projects_id_workspace_id_key" ON "projects"("id", "workspace_id");
CREATE UNIQUE INDEX "activity_logs_id_workspace_id_key" ON "activity_logs"("id", "workspace_id");
CREATE UNIQUE INDEX "domains_id_workspace_id_key" ON "domains"("id", "workspace_id");
CREATE UNIQUE INDEX "ideas_id_workspace_id_key" ON "ideas"("id", "workspace_id");
CREATE UNIQUE INDEX "user_layouts_id_workspace_id_key" ON "user_layouts"("id", "workspace_id");
-- ============================================
-- Missing index on agent_tasks.agent_type
-- Defined as @@index([agentType]) in schema.prisma
-- ============================================
CREATE INDEX "agent_tasks_agent_type_idx" ON "agent_tasks"("agent_type");

View File

@@ -1,23 +0,0 @@
-- CreateEnum
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
-- CreateTable
CREATE TABLE "terminal_sessions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL DEFAULT 'Terminal',
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closed_at" TIMESTAMPTZ,
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
-- AddForeignKey
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,3 +0,0 @@
-- AlterTable: add tone and formality_level columns to personalities
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';

View File

@@ -3,7 +3,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"] previewFeatures = ["postgresqlExtensions"]
} }
@@ -207,11 +206,6 @@ enum CredentialScope {
SYSTEM SYSTEM
} }
enum TerminalSessionStatus {
ACTIVE
CLOSED
}
// ============================================ // ============================================
// MODELS // MODELS
// ============================================ // ============================================
@@ -227,14 +221,6 @@ model User {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// MS21: Admin, local auth, and invitation fields
deactivatedAt DateTime? @map("deactivated_at") @db.Timestamptz
isLocalAuth Boolean @default(false) @map("is_local_auth")
passwordHash String? @map("password_hash")
invitedBy String? @map("invited_by") @db.Uuid
invitationToken String? @unique @map("invitation_token")
invitedAt DateTime? @map("invited_at") @db.Timestamptz
// Relations // Relations
ownedWorkspaces Workspace[] @relation("WorkspaceOwner") ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[] workspaceMemberships WorkspaceMember[]
@@ -275,13 +261,12 @@ model UserPreference {
} }
model Workspace { model Workspace {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
ownerId String @map("owner_id") @db.Uuid ownerId String @map("owner_id") @db.Uuid
settings Json @default("{}") settings Json @default("{}")
matrixRoomId String? @map("matrix_room_id") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations // Relations
owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)
@@ -311,7 +296,6 @@ model Workspace {
federationEventSubscriptions FederationEventSubscription[] federationEventSubscriptions FederationEventSubscription[]
llmUsageLogs LlmUsageLog[] llmUsageLogs LlmUsageLog[]
userCredentials UserCredential[] userCredentials UserCredential[]
terminalSessions TerminalSession[]
@@index([ownerId]) @@index([ownerId])
@@map("workspaces") @@map("workspaces")
@@ -1076,10 +1060,6 @@ model Personality {
displayName String @map("display_name") displayName String @map("display_name")
description String? @db.Text description String? @db.Text
// Tone and formality
tone String @default("neutral")
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
// System prompt // System prompt
systemPrompt String @map("system_prompt") @db.Text systemPrompt String @map("system_prompt") @db.Text
@@ -1526,23 +1506,3 @@ model LlmUsageLog {
@@index([conversationId]) @@index([conversationId])
@@map("llm_usage_logs") @@map("llm_usage_logs")
} }
// ============================================
// TERMINAL MODULE
// ============================================
model TerminalSession {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
name String @default("Terminal")
status TerminalSessionStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
closedAt DateTime? @map("closed_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId])
@@index([workspaceId, status])
@@map("terminal_sessions")
}

View File

@@ -65,136 +65,6 @@ async function main() {
}, },
}); });
// ============================================
// WIDGET DEFINITIONS (global, not workspace-scoped)
// ============================================
const widgetDefs = [
{
name: "TasksWidget",
displayName: "Tasks",
description: "View and manage your tasks",
component: "TasksWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "CalendarWidget",
displayName: "Calendar",
description: "View upcoming events and schedule",
component: "CalendarWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "QuickCaptureWidget",
displayName: "Quick Capture",
description: "Quickly capture notes and tasks",
component: "QuickCaptureWidget",
defaultWidth: 2,
defaultHeight: 1,
minWidth: 2,
minHeight: 1,
maxWidth: 4,
maxHeight: 2,
configSchema: {},
},
{
name: "AgentStatusWidget",
displayName: "Agent Status",
description: "Monitor agent activity and status",
component: "AgentStatusWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
maxHeight: null,
configSchema: {},
},
{
name: "ActiveProjectsWidget",
displayName: "Active Projects & Agent Chains",
description: "View active projects and running agent sessions",
component: "ActiveProjectsWidget",
defaultWidth: 2,
defaultHeight: 3,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "TaskProgressWidget",
displayName: "Task Progress",
description: "Live progress of orchestrator agent tasks",
component: "TaskProgressWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
maxHeight: null,
configSchema: {},
},
{
name: "OrchestratorEventsWidget",
displayName: "Orchestrator Events",
description: "Recent orchestration events with stream/Matrix visibility",
component: "OrchestratorEventsWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
];
for (const wd of widgetDefs) {
await prisma.widgetDefinition.upsert({
where: { name: wd.name },
update: {
displayName: wd.displayName,
description: wd.description,
component: wd.component,
defaultWidth: wd.defaultWidth,
defaultHeight: wd.defaultHeight,
minWidth: wd.minWidth,
minHeight: wd.minHeight,
maxWidth: wd.maxWidth,
maxHeight: wd.maxHeight,
configSchema: wd.configSchema,
},
create: {
name: wd.name,
displayName: wd.displayName,
description: wd.description,
component: wd.component,
defaultWidth: wd.defaultWidth,
defaultHeight: wd.defaultHeight,
minWidth: wd.minWidth,
minHeight: wd.minHeight,
maxWidth: wd.maxWidth,
maxHeight: wd.maxHeight,
configSchema: wd.configSchema,
},
});
}
console.log(`Seeded ${widgetDefs.length} widget definitions`);
// Use transaction for atomic seed data reset and creation // Use transaction for atomic seed data reset and creation
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Delete existing seed data for idempotency (avoids duplicates on re-run) // Delete existing seed data for idempotency (avoids duplicates on re-run)

View File

@@ -1,258 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import { WorkspaceMemberRole } from "@prisma/client";
import type { ExecutionContext } from "@nestjs/common";
describe("AdminController", () => {
let controller: AdminController;
let service: AdminService;
const mockAdminService = {
listUsers: vi.fn(),
inviteUser: vi.fn(),
updateUser: vi.fn(),
deactivateUser: vi.fn(),
createWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
};
const mockAuthGuard = {
canActivate: vi.fn((context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
request.user = {
id: "550e8400-e29b-41d4-a716-446655440001",
email: "admin@example.com",
name: "Admin User",
};
return true;
}),
};
const mockAdminGuard = {
canActivate: vi.fn(() => true),
};
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
const mockAdminUser = {
id: mockAdminId,
email: "admin@example.com",
name: "Admin User",
};
const mockUserResponse = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
emailVerified: false,
image: null,
createdAt: new Date("2026-01-01"),
deactivatedAt: null,
isLocalAuth: false,
invitedAt: null,
invitedBy: null,
workspaceMemberships: [],
};
const mockWorkspaceResponse = {
id: mockWorkspaceId,
name: "Test Workspace",
ownerId: mockAdminId,
settings: {},
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
memberCount: 1,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [
{
provide: AdminService,
useValue: mockAdminService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.overrideGuard(AdminGuard)
.useValue(mockAdminGuard)
.compile();
controller = module.get<AdminController>(AdminController);
service = module.get<AdminService>(AdminService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("listUsers", () => {
it("should return paginated users", async () => {
const paginatedResult = {
data: [mockUserResponse],
meta: { total: 1, page: 1, limit: 50, totalPages: 1 },
};
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
const result = await controller.listUsers({ page: 1, limit: 50 });
expect(result).toEqual(paginatedResult);
expect(service.listUsers).toHaveBeenCalledWith(1, 50);
});
it("should use default pagination", async () => {
const paginatedResult = {
data: [],
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
};
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
await controller.listUsers({});
expect(service.listUsers).toHaveBeenCalledWith(undefined, undefined);
});
});
describe("inviteUser", () => {
it("should invite a user", async () => {
const inviteDto = { email: "new@example.com" };
const invitationResponse = {
userId: "new-id",
invitationToken: "token",
email: "new@example.com",
invitedAt: new Date(),
};
mockAdminService.inviteUser.mockResolvedValue(invitationResponse);
const result = await controller.inviteUser(inviteDto, mockAdminUser);
expect(result).toEqual(invitationResponse);
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
});
it("should invite a user with workspace and role", async () => {
const inviteDto = {
email: "new@example.com",
workspaceId: mockWorkspaceId,
role: WorkspaceMemberRole.ADMIN,
};
mockAdminService.inviteUser.mockResolvedValue({
userId: "new-id",
invitationToken: "token",
email: "new@example.com",
invitedAt: new Date(),
});
await controller.inviteUser(inviteDto, mockAdminUser);
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
});
});
describe("updateUser", () => {
it("should update a user", async () => {
const updateDto = { name: "Updated Name" };
mockAdminService.updateUser.mockResolvedValue({
...mockUserResponse,
name: "Updated Name",
});
const result = await controller.updateUser(mockUserId, updateDto);
expect(result.name).toBe("Updated Name");
expect(service.updateUser).toHaveBeenCalledWith(mockUserId, updateDto);
});
it("should deactivate a user via update", async () => {
const deactivatedAt = "2026-02-28T00:00:00.000Z";
const updateDto = { deactivatedAt };
mockAdminService.updateUser.mockResolvedValue({
...mockUserResponse,
deactivatedAt: new Date(deactivatedAt),
});
const result = await controller.updateUser(mockUserId, updateDto);
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
});
});
describe("deactivateUser", () => {
it("should soft-delete a user", async () => {
mockAdminService.deactivateUser.mockResolvedValue({
...mockUserResponse,
deactivatedAt: new Date(),
});
const result = await controller.deactivateUser(mockUserId);
expect(result.deactivatedAt).toBeDefined();
expect(service.deactivateUser).toHaveBeenCalledWith(mockUserId);
});
});
describe("createWorkspace", () => {
it("should create a workspace", async () => {
const createDto = { name: "New Workspace", ownerId: mockAdminId };
mockAdminService.createWorkspace.mockResolvedValue(mockWorkspaceResponse);
const result = await controller.createWorkspace(createDto);
expect(result).toEqual(mockWorkspaceResponse);
expect(service.createWorkspace).toHaveBeenCalledWith(createDto);
});
it("should create workspace with settings", async () => {
const createDto = {
name: "New Workspace",
ownerId: mockAdminId,
settings: { feature: true },
};
mockAdminService.createWorkspace.mockResolvedValue({
...mockWorkspaceResponse,
settings: { feature: true },
});
const result = await controller.createWorkspace(createDto);
expect(result.settings).toEqual({ feature: true });
});
});
describe("updateWorkspace", () => {
it("should update a workspace", async () => {
const updateDto = { name: "Updated Workspace" };
mockAdminService.updateWorkspace.mockResolvedValue({
...mockWorkspaceResponse,
name: "Updated Workspace",
});
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
expect(result.name).toBe("Updated Workspace");
expect(service.updateWorkspace).toHaveBeenCalledWith(mockWorkspaceId, updateDto);
});
it("should update workspace settings", async () => {
const updateDto = { settings: { notifications: false } };
mockAdminService.updateWorkspace.mockResolvedValue({
...mockWorkspaceResponse,
settings: { notifications: false },
});
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
expect(result.settings).toEqual({ notifications: false });
});
});
});

View File

@@ -1,64 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from "@nestjs/common";
import { AdminService } from "./admin.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { AuthUser } from "@mosaic/shared";
import { InviteUserDto } from "./dto/invite-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { CreateWorkspaceDto } from "./dto/create-workspace.dto";
import { UpdateWorkspaceDto } from "./dto/update-workspace.dto";
import { QueryUsersDto } from "./dto/query-users.dto";
@Controller("admin")
@UseGuards(AuthGuard, AdminGuard)
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get("users")
async listUsers(@Query() query: QueryUsersDto) {
return this.adminService.listUsers(query.page, query.limit);
}
@Post("users/invite")
async inviteUser(@Body() dto: InviteUserDto, @CurrentUser() user: AuthUser) {
return this.adminService.inviteUser(dto, user.id);
}
@Patch("users/:id")
async updateUser(
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
@Body() dto: UpdateUserDto
) {
return this.adminService.updateUser(id, dto);
}
@Delete("users/:id")
async deactivateUser(@Param("id", new ParseUUIDPipe({ version: "4" })) id: string) {
return this.adminService.deactivateUser(id);
}
@Post("workspaces")
async createWorkspace(@Body() dto: CreateWorkspaceDto) {
return this.adminService.createWorkspace(dto);
}
@Patch("workspaces/:id")
async updateWorkspace(
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
@Body() dto: UpdateWorkspaceDto
) {
return this.adminService.updateWorkspace(id, dto);
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -1,477 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { AdminService } from "./admin.service";
import { PrismaService } from "../prisma/prisma.service";
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { WorkspaceMemberRole } from "@prisma/client";
describe("AdminService", () => {
let service: AdminService;
const mockPrismaService = {
user: {
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
workspace: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
workspaceMember: {
create: vi.fn(),
},
session: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(async (ops) => {
if (typeof ops === "function") {
return ops(mockPrismaService);
}
return Promise.all(ops);
}),
};
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
const mockUser = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
emailVerified: false,
image: null,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
deactivatedAt: null,
isLocalAuth: false,
passwordHash: null,
invitedBy: null,
invitationToken: null,
invitedAt: null,
authProviderId: null,
preferences: {},
workspaceMemberships: [
{
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-01-01"),
workspace: { id: mockWorkspaceId, name: "Test Workspace" },
},
],
};
const mockWorkspace = {
id: mockWorkspaceId,
name: "Test Workspace",
ownerId: mockAdminId,
settings: {},
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
matrixRoomId: null,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<AdminService>(AdminService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("listUsers", () => {
it("should return paginated users with memberships", async () => {
mockPrismaService.user.findMany.mockResolvedValue([mockUser]);
mockPrismaService.user.count.mockResolvedValue(1);
const result = await service.listUsers(1, 50);
expect(result.data).toHaveLength(1);
expect(result.data[0]?.id).toBe(mockUserId);
expect(result.data[0]?.workspaceMemberships).toHaveLength(1);
expect(result.meta).toEqual({
total: 1,
page: 1,
limit: 50,
totalPages: 1,
});
});
it("should use default pagination when not provided", async () => {
mockPrismaService.user.findMany.mockResolvedValue([]);
mockPrismaService.user.count.mockResolvedValue(0);
await service.listUsers();
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 0,
take: 50,
})
);
});
it("should calculate pagination correctly", async () => {
mockPrismaService.user.findMany.mockResolvedValue([]);
mockPrismaService.user.count.mockResolvedValue(150);
const result = await service.listUsers(3, 25);
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 50,
take: 25,
})
);
expect(result.meta.totalPages).toBe(6);
});
});
describe("inviteUser", () => {
it("should create a user with invitation token", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const createdUser = {
id: "new-user-id",
email: "new@example.com",
name: "new",
invitationToken: "some-token",
};
mockPrismaService.user.create.mockResolvedValue(createdUser);
const result = await service.inviteUser({ email: "new@example.com" }, mockAdminId);
expect(result.email).toBe("new@example.com");
expect(result.invitationToken).toBeDefined();
expect(result.userId).toBe("new-user-id");
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: "new@example.com",
invitedBy: mockAdminId,
invitationToken: expect.any(String),
}),
})
);
});
it("should add user to workspace when workspaceId provided", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
const createdUser = { id: "new-user-id", email: "new@example.com", name: "new" };
mockPrismaService.user.create.mockResolvedValue(createdUser);
await service.inviteUser(
{
email: "new@example.com",
workspaceId: mockWorkspaceId,
role: WorkspaceMemberRole.ADMIN,
},
mockAdminId
);
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
userId: "new-user-id",
role: WorkspaceMemberRole.ADMIN,
},
});
});
it("should throw ConflictException if email already exists", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
await expect(service.inviteUser({ email: "test@example.com" }, mockAdminId)).rejects.toThrow(
ConflictException
);
});
it("should throw NotFoundException if workspace does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
await expect(
service.inviteUser({ email: "new@example.com", workspaceId: "non-existent" }, mockAdminId)
).rejects.toThrow(NotFoundException);
});
it("should use email prefix as default name", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const createdUser = { id: "new-user-id", email: "jane.doe@example.com", name: "jane.doe" };
mockPrismaService.user.create.mockResolvedValue(createdUser);
await service.inviteUser({ email: "jane.doe@example.com" }, mockAdminId);
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: "jane.doe",
}),
})
);
});
it("should use provided name when given", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const createdUser = { id: "new-user-id", email: "j@example.com", name: "Jane Doe" };
mockPrismaService.user.create.mockResolvedValue(createdUser);
await service.inviteUser({ email: "j@example.com", name: "Jane Doe" }, mockAdminId);
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: "Jane Doe",
}),
})
);
});
});
describe("updateUser", () => {
it("should update user fields", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
name: "Updated Name",
});
const result = await service.updateUser(mockUserId, { name: "Updated Name" });
expect(result.name).toBe("Updated Name");
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockUserId },
data: { name: "Updated Name" },
})
);
});
it("should set deactivatedAt when provided", async () => {
const deactivatedAt = "2026-02-28T00:00:00.000Z";
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(deactivatedAt),
});
const result = await service.updateUser(mockUserId, { deactivatedAt });
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
});
it("should clear deactivatedAt when set to null", async () => {
const deactivatedUser = { ...mockUser, deactivatedAt: new Date() };
mockPrismaService.user.findUnique.mockResolvedValue(deactivatedUser);
mockPrismaService.user.update.mockResolvedValue({
...deactivatedUser,
deactivatedAt: null,
});
const result = await service.updateUser(mockUserId, { deactivatedAt: null });
expect(result.deactivatedAt).toBeNull();
});
it("should throw NotFoundException if user does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(service.updateUser("non-existent", { name: "Test" })).rejects.toThrow(
NotFoundException
);
});
it("should update emailVerified", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
emailVerified: true,
});
const result = await service.updateUser(mockUserId, { emailVerified: true });
expect(result.emailVerified).toBe(true);
});
it("should update preferences", async () => {
const prefs = { theme: "dark" };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
preferences: prefs,
});
await service.updateUser(mockUserId, { preferences: prefs });
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ preferences: prefs }),
})
);
});
});
describe("deactivateUser", () => {
it("should set deactivatedAt and invalidate sessions", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(),
});
mockPrismaService.session.deleteMany.mockResolvedValue({ count: 3 });
const result = await service.deactivateUser(mockUserId);
expect(result.deactivatedAt).toBeDefined();
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockUserId },
data: { deactivatedAt: expect.any(Date) },
})
);
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({ where: { userId: mockUserId } });
});
it("should throw NotFoundException if user does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(service.deactivateUser("non-existent")).rejects.toThrow(NotFoundException);
});
it("should throw BadRequestException if user is already deactivated", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(),
});
await expect(service.deactivateUser(mockUserId)).rejects.toThrow(BadRequestException);
});
});
describe("createWorkspace", () => {
it("should create a workspace with owner membership", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.workspace.create.mockResolvedValue(mockWorkspace);
const result = await service.createWorkspace({
name: "New Workspace",
ownerId: mockAdminId,
});
expect(result.name).toBe("Test Workspace");
expect(result.memberCount).toBe(1);
expect(mockPrismaService.workspace.create).toHaveBeenCalled();
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspace.id,
userId: mockAdminId,
role: WorkspaceMemberRole.OWNER,
},
});
});
it("should throw NotFoundException if owner does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(
service.createWorkspace({ name: "New Workspace", ownerId: "non-existent" })
).rejects.toThrow(NotFoundException);
});
it("should pass settings when provided", async () => {
const settings = { theme: "dark", features: ["chat"] };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.workspace.create.mockResolvedValue({
...mockWorkspace,
settings,
});
await service.createWorkspace({
name: "New Workspace",
ownerId: mockAdminId,
settings,
});
expect(mockPrismaService.workspace.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ settings }),
})
);
});
});
describe("updateWorkspace", () => {
it("should update workspace name", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
mockPrismaService.workspace.update.mockResolvedValue({
...mockWorkspace,
name: "Updated Workspace",
_count: { members: 3 },
});
const result = await service.updateWorkspace(mockWorkspaceId, {
name: "Updated Workspace",
});
expect(result.name).toBe("Updated Workspace");
expect(result.memberCount).toBe(3);
});
it("should update workspace settings", async () => {
const newSettings = { notifications: true };
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
mockPrismaService.workspace.update.mockResolvedValue({
...mockWorkspace,
settings: newSettings,
_count: { members: 1 },
});
const result = await service.updateWorkspace(mockWorkspaceId, {
settings: newSettings,
});
expect(result.settings).toEqual(newSettings);
});
it("should throw NotFoundException if workspace does not exist", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
await expect(service.updateWorkspace("non-existent", { name: "Test" })).rejects.toThrow(
NotFoundException
);
});
it("should only update provided fields", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
mockPrismaService.workspace.update.mockResolvedValue({
...mockWorkspace,
_count: { members: 1 },
});
await service.updateWorkspace(mockWorkspaceId, { name: "Only Name" });
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { name: "Only Name" },
})
);
});
});
});

View File

@@ -1,309 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
import { randomUUID } from "node:crypto";
import { PrismaService } from "../prisma/prisma.service";
import type { InviteUserDto } from "./dto/invite-user.dto";
import type { UpdateUserDto } from "./dto/update-user.dto";
import type { CreateWorkspaceDto } from "./dto/create-workspace.dto";
import type {
AdminUserResponse,
AdminWorkspaceResponse,
InvitationResponse,
PaginatedResponse,
} from "./types/admin.types";
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(private readonly prisma: PrismaService) {}
async listUsers(page = 1, limit = 50): Promise<PaginatedResponse<AdminUserResponse>> {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
this.prisma.user.count(),
]);
return {
data: users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
isLocalAuth: user.isLocalAuth,
invitedAt: user.invitedAt,
invitedBy: user.invitedBy,
workspaceMemberships: user.workspaceMemberships.map((m) => ({
workspaceId: m.workspaceId,
workspaceName: m.workspace.name,
role: m.role,
joinedAt: m.joinedAt,
})),
})),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async inviteUser(dto: InviteUserDto, inviterId: string): Promise<InvitationResponse> {
const existing = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException(`User with email ${dto.email} already exists`);
}
if (dto.workspaceId) {
const workspace = await this.prisma.workspace.findUnique({
where: { id: dto.workspaceId },
});
if (!workspace) {
throw new NotFoundException(`Workspace ${dto.workspaceId} not found`);
}
}
const invitationToken = randomUUID();
const now = new Date();
const user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
email: dto.email,
name: dto.name ?? dto.email.split("@")[0] ?? dto.email,
emailVerified: false,
invitedBy: inviterId,
invitationToken,
invitedAt: now,
},
});
if (dto.workspaceId) {
await tx.workspaceMember.create({
data: {
workspaceId: dto.workspaceId,
userId: created.id,
role: dto.role ?? WorkspaceMemberRole.MEMBER,
},
});
}
return created;
});
this.logger.log(`User invited: ${user.email} by ${inviterId}`);
return {
userId: user.id,
invitationToken,
email: user.email,
invitedAt: now,
};
}
async updateUser(id: string, dto: UpdateUserDto): Promise<AdminUserResponse> {
const existing = await this.prisma.user.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`User ${id} not found`);
}
const data: Prisma.UserUpdateInput = {};
if (dto.name !== undefined) {
data.name = dto.name;
}
if (dto.emailVerified !== undefined) {
data.emailVerified = dto.emailVerified;
}
if (dto.preferences !== undefined) {
data.preferences = dto.preferences as Prisma.InputJsonValue;
}
if (dto.deactivatedAt !== undefined) {
data.deactivatedAt = dto.deactivatedAt ? new Date(dto.deactivatedAt) : null;
}
const user = await this.prisma.user.update({
where: { id },
data,
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
});
this.logger.log(`User updated: ${id}`);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
isLocalAuth: user.isLocalAuth,
invitedAt: user.invitedAt,
invitedBy: user.invitedBy,
workspaceMemberships: user.workspaceMemberships.map((m) => ({
workspaceId: m.workspaceId,
workspaceName: m.workspace.name,
role: m.role,
joinedAt: m.joinedAt,
})),
};
}
async deactivateUser(id: string): Promise<AdminUserResponse> {
const existing = await this.prisma.user.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`User ${id} not found`);
}
if (existing.deactivatedAt) {
throw new BadRequestException(`User ${id} is already deactivated`);
}
const [user] = await this.prisma.$transaction([
this.prisma.user.update({
where: { id },
data: { deactivatedAt: new Date() },
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
}),
this.prisma.session.deleteMany({ where: { userId: id } }),
]);
this.logger.log(`User deactivated and sessions invalidated: ${id}`);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
isLocalAuth: user.isLocalAuth,
invitedAt: user.invitedAt,
invitedBy: user.invitedBy,
workspaceMemberships: user.workspaceMemberships.map((m) => ({
workspaceId: m.workspaceId,
workspaceName: m.workspace.name,
role: m.role,
joinedAt: m.joinedAt,
})),
};
}
async createWorkspace(dto: CreateWorkspaceDto): Promise<AdminWorkspaceResponse> {
const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } });
if (!owner) {
throw new NotFoundException(`User ${dto.ownerId} not found`);
}
const workspace = await this.prisma.$transaction(async (tx) => {
const created = await tx.workspace.create({
data: {
name: dto.name,
ownerId: dto.ownerId,
settings: dto.settings ? (dto.settings as Prisma.InputJsonValue) : {},
},
});
await tx.workspaceMember.create({
data: {
workspaceId: created.id,
userId: dto.ownerId,
role: WorkspaceMemberRole.OWNER,
},
});
return created;
});
this.logger.log(`Workspace created: ${workspace.id} with owner ${dto.ownerId}`);
return {
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
settings: workspace.settings as Record<string, unknown>,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
memberCount: 1,
};
}
async updateWorkspace(
id: string,
dto: { name?: string; settings?: Record<string, unknown> }
): Promise<AdminWorkspaceResponse> {
const existing = await this.prisma.workspace.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`Workspace ${id} not found`);
}
const data: Prisma.WorkspaceUpdateInput = {};
if (dto.name !== undefined) {
data.name = dto.name;
}
if (dto.settings !== undefined) {
data.settings = dto.settings as Prisma.InputJsonValue;
}
const workspace = await this.prisma.workspace.update({
where: { id },
data,
include: {
_count: { select: { members: true } },
},
});
this.logger.log(`Workspace updated: ${id}`);
return {
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
settings: workspace.settings as Record<string, unknown>,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
memberCount: workspace._count.members,
};
}
}

View File

@@ -1,15 +0,0 @@
import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator";
export class CreateWorkspaceDto {
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string;
@IsUUID("4", { message: "ownerId must be a valid UUID" })
ownerId!: string;
@IsOptional()
@IsObject({ message: "settings must be an object" })
settings?: Record<string, unknown>;
}

View File

@@ -1,20 +0,0 @@
import { WorkspaceMemberRole } from "@prisma/client";
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
export class InviteUserDto {
@IsEmail({}, { message: "email must be a valid email address" })
email!: string;
@IsOptional()
@IsString({ message: "name must be a string" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId?: string;
@IsOptional()
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role?: WorkspaceMemberRole;
}

View File

@@ -1,15 +0,0 @@
import { WorkspaceMemberRole } from "@prisma/client";
import { IsEnum, IsUUID } from "class-validator";
export class AddMemberDto {
@IsUUID("4", { message: "userId must be a valid UUID" })
userId!: string;
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role!: WorkspaceMemberRole;
}
export class UpdateMemberRoleDto {
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role!: WorkspaceMemberRole;
}

View File

@@ -1,17 +0,0 @@
import { IsInt, IsOptional, Max, Min } from "class-validator";
import { Type } from "class-transformer";
export class QueryUsersDto {
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number;
}

View File

@@ -1,27 +0,0 @@
import {
IsBoolean,
IsDateString,
IsObject,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
export class UpdateUserDto {
@IsOptional()
@IsString({ message: "name must be a string" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsDateString({}, { message: "deactivatedAt must be a valid ISO 8601 date string" })
deactivatedAt?: string | null;
@IsOptional()
@IsBoolean({ message: "emailVerified must be a boolean" })
emailVerified?: boolean;
@IsOptional()
@IsObject({ message: "preferences must be an object" })
preferences?: Record<string, unknown>;
}

View File

@@ -1,13 +0,0 @@
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
export class UpdateWorkspaceDto {
@IsOptional()
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsObject({ message: "settings must be an object" })
settings?: Record<string, unknown>;
}

View File

@@ -1,49 +0,0 @@
import type { WorkspaceMemberRole } from "@prisma/client";
export interface AdminUserResponse {
id: string;
name: string;
email: string;
emailVerified: boolean;
image: string | null;
createdAt: Date;
deactivatedAt: Date | null;
isLocalAuth: boolean;
invitedAt: Date | null;
invitedBy: string | null;
workspaceMemberships: WorkspaceMembershipResponse[];
}
export interface WorkspaceMembershipResponse {
workspaceId: string;
workspaceName: string;
role: WorkspaceMemberRole;
joinedAt: Date;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface InvitationResponse {
userId: string;
invitationToken: string;
email: string;
invitedAt: Date;
}
export interface AdminWorkspaceResponse {
id: string;
name: string;
ownerId: string;
settings: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
memberCount: number;
}

View File

@@ -1,5 +1,4 @@
import { Controller, Get } from "@nestjs/common"; import { Controller, Get } from "@nestjs/common";
import { SkipThrottle } from "@nestjs/throttler";
import { AppService } from "./app.service"; import { AppService } from "./app.service";
import { PrismaService } from "./prisma/prisma.service"; import { PrismaService } from "./prisma/prisma.service";
import type { ApiResponse, HealthStatus } from "@mosaic/shared"; import type { ApiResponse, HealthStatus } from "@mosaic/shared";
@@ -18,7 +17,6 @@ export class AppController {
} }
@Get("health") @Get("health")
@SkipThrottle()
async getHealth(): Promise<ApiResponse<HealthStatus>> { async getHealth(): Promise<ApiResponse<HealthStatus>> {
const dbHealthy = await this.prisma.isHealthy(); const dbHealthy = await this.prisma.isHealthy();
const dbInfo = await this.prisma.getConnectionInfo(); const dbInfo = await this.prisma.getConnectionInfo();

View File

@@ -38,14 +38,6 @@ import { CoordinatorIntegrationModule } from "./coordinator-integration/coordina
import { FederationModule } from "./federation/federation.module"; import { FederationModule } from "./federation/federation.module";
import { CredentialsModule } from "./credentials/credentials.module"; import { CredentialsModule } from "./credentials/credentials.module";
import { MosaicTelemetryModule } from "./mosaic-telemetry"; import { MosaicTelemetryModule } from "./mosaic-telemetry";
import { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.module";
import { PersonalitiesModule } from "./personalities/personalities.module";
import { WorkspacesModule } from "./workspaces/workspaces.module";
import { AdminModule } from "./admin/admin.module";
import { TeamsModule } from "./teams/teams.module";
import { ImportModule } from "./import/import.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({ @Module({
@@ -107,14 +99,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
FederationModule, FederationModule,
CredentialsModule, CredentialsModule,
MosaicTelemetryModule, MosaicTelemetryModule,
SpeechModule,
DashboardModule,
TerminalModule,
PersonalitiesModule,
WorkspacesModule,
AdminModule,
TeamsModule,
ImportModule,
], ],
controllers: [AppController, CsrfController], controllers: [AppController, CsrfController],
providers: [ providers: [

View File

@@ -12,10 +12,7 @@ import { PrismaClient, Prisma } from "@prisma/client";
import { randomUUID as uuid } from "crypto"; import { randomUUID as uuid } from "crypto";
import { runWithRlsClient, getRlsClient } from "../prisma/rls-context.provider"; import { runWithRlsClient, getRlsClient } from "../prisma/rls-context.provider";
const shouldRunDbIntegrationTests = describe.skipIf(!process.env.DATABASE_URL)(
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
describe.skipIf(!shouldRunDbIntegrationTests)(
"Auth Tables RLS Policies (requires DATABASE_URL)", "Auth Tables RLS Policies (requires DATABASE_URL)",
() => { () => {
let prisma: PrismaClient; let prisma: PrismaClient;
@@ -31,7 +28,7 @@ describe.skipIf(!shouldRunDbIntegrationTests)(
beforeAll(async () => { beforeAll(async () => {
// Skip setup if DATABASE_URL is not available // Skip setup if DATABASE_URL is not available
if (!shouldRunDbIntegrationTests) { if (!process.env.DATABASE_URL) {
return; return;
} }
@@ -52,7 +49,7 @@ describe.skipIf(!shouldRunDbIntegrationTests)(
afterAll(async () => { afterAll(async () => {
// Skip cleanup if DATABASE_URL is not available or prisma not initialized // Skip cleanup if DATABASE_URL is not available or prisma not initialized
if (!shouldRunDbIntegrationTests || !prisma) { if (!process.env.DATABASE_URL || !prisma) {
return; return;
} }

View File

@@ -1,30 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { PrismaClient } from "@prisma/client"; import { isOidcEnabled, validateOidcConfig } from "./auth.config";
// Mock better-auth modules to inspect genericOAuth plugin configuration
const mockGenericOAuth = vi.fn().mockReturnValue({ id: "generic-oauth" });
const mockBetterAuth = vi.fn().mockReturnValue({ handler: vi.fn() });
const mockPrismaAdapter = vi.fn().mockReturnValue({});
vi.mock("better-auth/plugins", () => ({
genericOAuth: (...args: unknown[]) => mockGenericOAuth(...args),
}));
vi.mock("better-auth", () => ({
betterAuth: (...args: unknown[]) => mockBetterAuth(...args),
}));
vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
}));
import {
isOidcEnabled,
validateOidcConfig,
createAuth,
getTrustedOrigins,
getBetterAuthBaseUrl,
} from "./auth.config";
describe("auth.config", () => { describe("auth.config", () => {
// Store original env vars to restore after each test // Store original env vars to restore after each test
@@ -36,13 +11,6 @@ describe("auth.config", () => {
delete process.env.OIDC_ISSUER; delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID; delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET; delete process.env.OIDC_CLIENT_SECRET;
delete process.env.OIDC_REDIRECT_URI;
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_URL;
delete process.env.NEXT_PUBLIC_APP_URL;
delete process.env.NEXT_PUBLIC_API_URL;
delete process.env.TRUSTED_ORIGINS;
delete process.env.COOKIE_DOMAIN;
}); });
afterEach(() => { afterEach(() => {
@@ -102,7 +70,6 @@ describe("auth.config", () => {
it("should throw when OIDC_ISSUER is missing", () => { it("should throw when OIDC_ISSUER is missing", () => {
process.env.OIDC_CLIENT_ID = "test-client-id"; process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret"; process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER"); expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled"); expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
@@ -111,7 +78,6 @@ describe("auth.config", () => {
it("should throw when OIDC_CLIENT_ID is missing", () => { it("should throw when OIDC_CLIENT_ID is missing", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/"; process.env.OIDC_ISSUER = "https://auth.example.com/";
process.env.OIDC_CLIENT_SECRET = "test-client-secret"; process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID"); expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
}); });
@@ -119,22 +85,13 @@ describe("auth.config", () => {
it("should throw when OIDC_CLIENT_SECRET is missing", () => { it("should throw when OIDC_CLIENT_SECRET is missing", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/"; process.env.OIDC_ISSUER = "https://auth.example.com/";
process.env.OIDC_CLIENT_ID = "test-client-id"; process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET"); expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
}); });
it("should throw when OIDC_REDIRECT_URI is missing", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
expect(() => validateOidcConfig()).toThrow("OIDC_REDIRECT_URI");
});
it("should throw when all required vars are missing", () => { it("should throw when all required vars are missing", () => {
expect(() => validateOidcConfig()).toThrow( expect(() => validateOidcConfig()).toThrow(
"OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI" "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"
); );
}); });
@@ -142,10 +99,9 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = ""; process.env.OIDC_ISSUER = "";
process.env.OIDC_CLIENT_ID = ""; process.env.OIDC_CLIENT_ID = "";
process.env.OIDC_CLIENT_SECRET = ""; process.env.OIDC_CLIENT_SECRET = "";
process.env.OIDC_REDIRECT_URI = "";
expect(() => validateOidcConfig()).toThrow( expect(() => validateOidcConfig()).toThrow(
"OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI" "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"
); );
}); });
@@ -153,7 +109,6 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = " "; process.env.OIDC_ISSUER = " ";
process.env.OIDC_CLIENT_ID = "test-client-id"; process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret"; process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER"); expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
}); });
@@ -162,7 +117,6 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic"; process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
process.env.OIDC_CLIENT_ID = "test-client-id"; process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret"; process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash"); expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic"); expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
@@ -172,7 +126,6 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/"; process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id"; process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret"; process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow(); expect(() => validateOidcConfig()).not.toThrow();
}); });
@@ -180,537 +133,6 @@ describe("auth.config", () => {
it("should suggest disabling OIDC in error message", () => { it("should suggest disabling OIDC in error message", () => {
expect(() => validateOidcConfig()).toThrow("OIDC_ENABLED=false"); expect(() => validateOidcConfig()).toThrow("OIDC_ENABLED=false");
}); });
describe("OIDC_REDIRECT_URI validation", () => {
beforeEach(() => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
});
it("should throw when OIDC_REDIRECT_URI is not a valid URL", () => {
process.env.OIDC_REDIRECT_URI = "not-a-url";
expect(() => validateOidcConfig()).toThrow("OIDC_REDIRECT_URI must be a valid URL");
expect(() => validateOidcConfig()).toThrow("not-a-url");
expect(() => validateOidcConfig()).toThrow("Parse error:");
});
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/oauth2/callback", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
expect(() => validateOidcConfig()).toThrow(
'OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback"'
);
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
});
it("should accept a valid OIDC_REDIRECT_URI with /auth/oauth2/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow();
});
it("should accept OIDC_REDIRECT_URI with exactly /auth/oauth2/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback";
expect(() => validateOidcConfig()).not.toThrow();
});
it("should warn but not throw when using localhost in production", () => {
process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => validateOidcConfig()).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("OIDC_REDIRECT_URI uses localhost")
);
warnSpy.mockRestore();
});
it("should warn but not throw when using 127.0.0.1 in production", () => {
process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => validateOidcConfig()).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("OIDC_REDIRECT_URI uses localhost")
);
warnSpy.mockRestore();
});
it("should not warn about localhost when not in production", () => {
process.env.NODE_ENV = "development";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => validateOidcConfig()).not.toThrow();
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
});
});
describe("createAuth - genericOAuth PKCE configuration", () => {
beforeEach(() => {
mockGenericOAuth.mockClear();
mockBetterAuth.mockClear();
mockPrismaAdapter.mockClear();
});
it("should enable PKCE in the genericOAuth provider config when OIDC is enabled", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockGenericOAuth).toHaveBeenCalledOnce();
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
config: Array<{ pkce?: boolean; redirectURI?: string }>;
};
expect(callArgs.config[0].pkce).toBe(true);
expect(callArgs.config[0].redirectURI).toBe(
"https://app.example.com/auth/oauth2/callback/authentik"
);
});
it("should not call genericOAuth when OIDC is disabled", () => {
process.env.OIDC_ENABLED = "false";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockGenericOAuth).not.toHaveBeenCalled();
});
it("should throw if OIDC_CLIENT_ID is missing when OIDC is enabled", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_CLIENT_ID deliberately not set
// validateOidcConfig will throw first, so we need to bypass it
// by setting the var then deleting it after validation
// Instead, test via the validation path which is fine — but let's
// verify the plugin-level guard by using a direct approach:
// Set env to pass validateOidcConfig, then delete OIDC_CLIENT_ID
// The validateOidcConfig will catch this first, which is correct behavior
const mockPrisma = {} as PrismaClient;
expect(() => createAuth(mockPrisma)).toThrow("OIDC_CLIENT_ID");
});
it("should throw if OIDC_CLIENT_SECRET is missing when OIDC is enabled", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_CLIENT_SECRET deliberately not set
const mockPrisma = {} as PrismaClient;
expect(() => createAuth(mockPrisma)).toThrow("OIDC_CLIENT_SECRET");
});
it("should throw if OIDC_ISSUER is missing when OIDC is enabled", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_ISSUER deliberately not set
const mockPrisma = {} as PrismaClient;
expect(() => createAuth(mockPrisma)).toThrow("OIDC_ISSUER");
});
});
describe("getTrustedOrigins", () => {
it("should return localhost URLs when NODE_ENV is not production", () => {
process.env.NODE_ENV = "development";
const origins = getTrustedOrigins();
expect(origins).toContain("http://localhost:3000");
expect(origins).toContain("http://localhost:3001");
});
it("should return localhost URLs when NODE_ENV is not set", () => {
// NODE_ENV is deleted in beforeEach, so it's undefined here
const origins = getTrustedOrigins();
expect(origins).toContain("http://localhost:3000");
expect(origins).toContain("http://localhost:3001");
});
it("should exclude localhost URLs in production", () => {
process.env.NODE_ENV = "production";
const origins = getTrustedOrigins();
expect(origins).not.toContain("http://localhost:3000");
expect(origins).not.toContain("http://localhost:3001");
});
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,https://api.mosaicstack.dev";
const origins = getTrustedOrigins();
expect(origins).toContain("https://app.mosaicstack.dev");
expect(origins).toContain("https://api.mosaicstack.dev");
});
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
process.env.TRUSTED_ORIGINS = " https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
const origins = getTrustedOrigins();
expect(origins).toContain("https://app.mosaicstack.dev");
expect(origins).toContain("https://api.mosaicstack.dev");
});
it("should filter out empty strings from TRUSTED_ORIGINS", () => {
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,,, ,";
const origins = getTrustedOrigins();
expect(origins).toContain("https://app.mosaicstack.dev");
// No empty strings in the result
origins.forEach((o) => expect(o).not.toBe(""));
});
it("should include NEXT_PUBLIC_APP_URL", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://my-app.example.com";
const origins = getTrustedOrigins();
expect(origins).toContain("https://my-app.example.com");
});
it("should include NEXT_PUBLIC_API_URL", () => {
process.env.NEXT_PUBLIC_API_URL = "https://my-api.example.com";
const origins = getTrustedOrigins();
expect(origins).toContain("https://my-api.example.com");
});
it("should deduplicate origins", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
process.env.TRUSTED_ORIGINS = "http://localhost:3000,http://localhost:3001";
// NODE_ENV not set, so localhost fallbacks are also added
const origins = getTrustedOrigins();
const countLocalhost3000 = origins.filter((o) => o === "http://localhost:3000").length;
const countLocalhost3001 = origins.filter((o) => o === "http://localhost:3001").length;
expect(countLocalhost3000).toBe(1);
expect(countLocalhost3001).toBe(1);
});
it("should handle all env vars missing gracefully", () => {
// All env vars deleted in beforeEach; NODE_ENV is also deleted (not production)
const origins = getTrustedOrigins();
// Should still return localhost fallbacks since not in production
expect(origins).toContain("http://localhost:3000");
expect(origins).toContain("http://localhost:3001");
expect(origins).toHaveLength(2);
});
it("should return empty array when all env vars missing in production", () => {
process.env.NODE_ENV = "production";
const origins = getTrustedOrigins();
expect(origins).toHaveLength(0);
});
it("should combine all sources correctly", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.mosaicstack.dev";
process.env.NEXT_PUBLIC_API_URL = "https://api.mosaicstack.dev";
process.env.TRUSTED_ORIGINS = "https://extra.example.com";
process.env.NODE_ENV = "development";
const origins = getTrustedOrigins();
expect(origins).toContain("https://app.mosaicstack.dev");
expect(origins).toContain("https://api.mosaicstack.dev");
expect(origins).toContain("https://extra.example.com");
expect(origins).toContain("http://localhost:3000");
expect(origins).toContain("http://localhost:3001");
expect(origins).toHaveLength(5);
});
it("should reject invalid URLs in TRUSTED_ORIGINS with a warning including error details", () => {
process.env.TRUSTED_ORIGINS = "not-a-url,https://valid.example.com";
process.env.NODE_ENV = "production";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const origins = getTrustedOrigins();
expect(origins).toContain("https://valid.example.com");
expect(origins).not.toContain("not-a-url");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Ignoring invalid URL in TRUSTED_ORIGINS: "not-a-url"')
);
// Verify that error detail is included in the warning
const warnCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("not-a-url")
);
expect(warnCall).toBeDefined();
expect(warnCall![0]).toMatch(/\(.*\)$/);
warnSpy.mockRestore();
});
it("should reject non-HTTP origins in TRUSTED_ORIGINS with a warning", () => {
process.env.TRUSTED_ORIGINS = "ftp://files.example.com,https://valid.example.com";
process.env.NODE_ENV = "production";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const origins = getTrustedOrigins();
expect(origins).toContain("https://valid.example.com");
expect(origins).not.toContain("ftp://files.example.com");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Ignoring non-HTTP origin in TRUSTED_ORIGINS")
);
warnSpy.mockRestore();
});
});
describe("createAuth - session and cookie configuration", () => {
beforeEach(() => {
mockGenericOAuth.mockClear();
mockBetterAuth.mockClear();
mockPrismaAdapter.mockClear();
});
it("should configure session expiresIn to 7 days (604800 seconds)", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
session: { expiresIn: number; updateAge: number };
};
expect(config.session.expiresIn).toBe(604800);
});
it("should configure session updateAge to 2 hours (7200 seconds)", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
session: { expiresIn: number; updateAge: number };
};
expect(config.session.updateAge).toBe(7200);
});
it("should configure BetterAuth database ID generation as UUID", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
database: {
generateId: string;
};
};
};
expect(config.advanced.database.generateId).toBe("uuid");
});
it("should set httpOnly cookie attribute to true", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
defaultCookieAttributes: {
httpOnly: boolean;
secure: boolean;
sameSite: string;
};
};
};
expect(config.advanced.defaultCookieAttributes.httpOnly).toBe(true);
});
it("should set sameSite cookie attribute to lax", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
defaultCookieAttributes: {
httpOnly: boolean;
secure: boolean;
sameSite: string;
};
};
};
expect(config.advanced.defaultCookieAttributes.sameSite).toBe("lax");
});
it("should set secure cookie attribute to true in production", () => {
process.env.NODE_ENV = "production";
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
defaultCookieAttributes: {
httpOnly: boolean;
secure: boolean;
sameSite: string;
};
};
};
expect(config.advanced.defaultCookieAttributes.secure).toBe(true);
});
it("should set secure cookie attribute to false in non-production", () => {
process.env.NODE_ENV = "development";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
defaultCookieAttributes: {
httpOnly: boolean;
secure: boolean;
sameSite: string;
};
};
};
expect(config.advanced.defaultCookieAttributes.secure).toBe(false);
});
it("should set cookie domain when COOKIE_DOMAIN env var is present", () => {
process.env.COOKIE_DOMAIN = ".mosaicstack.dev";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
defaultCookieAttributes: {
httpOnly: boolean;
secure: boolean;
sameSite: string;
domain?: string;
};
};
};
expect(config.advanced.defaultCookieAttributes.domain).toBe(".mosaicstack.dev");
});
it("should not set cookie domain when COOKIE_DOMAIN env var is absent", () => {
delete process.env.COOKIE_DOMAIN;
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
defaultCookieAttributes: {
httpOnly: boolean;
secure: boolean;
sameSite: string;
domain?: string;
};
};
};
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
});
});
describe("getBetterAuthBaseUrl", () => {
it("should prefer BETTER_AUTH_URL when set", () => {
process.env.BETTER_AUTH_URL = "https://auth-base.example.com";
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
expect(getBetterAuthBaseUrl()).toBe("https://auth-base.example.com");
});
it("should fall back to NEXT_PUBLIC_API_URL when BETTER_AUTH_URL is not set", () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
expect(getBetterAuthBaseUrl()).toBe("https://api.example.com");
});
it("should throw when base URL is invalid", () => {
process.env.BETTER_AUTH_URL = "not-a-url";
expect(() => getBetterAuthBaseUrl()).toThrow("BetterAuth base URL must be a valid URL");
});
it("should throw when base URL is missing in production", () => {
process.env.NODE_ENV = "production";
expect(() => getBetterAuthBaseUrl()).toThrow("Missing BetterAuth base URL in production");
});
it("should throw when base URL is not https in production", () => {
process.env.NODE_ENV = "production";
process.env.BETTER_AUTH_URL = "http://api.example.com";
expect(() => getBetterAuthBaseUrl()).toThrow(
"BetterAuth base URL must use https in production"
);
});
});
describe("createAuth - baseURL wiring", () => {
beforeEach(() => {
mockBetterAuth.mockClear();
mockPrismaAdapter.mockClear();
});
it("should pass BETTER_AUTH_URL into BetterAuth config", () => {
process.env.BETTER_AUTH_URL = "https://api.mosaicstack.dev";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
expect(config.baseURL).toBe("https://api.mosaicstack.dev");
});
it("should pass NEXT_PUBLIC_API_URL into BetterAuth config when BETTER_AUTH_URL is absent", () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.fallback.dev";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
expect(config.baseURL).toBe("https://api.fallback.dev");
}); });
}); });
}); });

View File

@@ -6,47 +6,7 @@ import type { PrismaClient } from "@prisma/client";
/** /**
* Required OIDC environment variables when OIDC is enabled * Required OIDC environment variables when OIDC is enabled
*/ */
const REQUIRED_OIDC_ENV_VARS = [ const REQUIRED_OIDC_ENV_VARS = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"] as const;
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
] as const;
/**
* Resolve BetterAuth base URL from explicit auth URL or API URL.
* BetterAuth uses this to generate absolute callback/error URLs.
*/
export function getBetterAuthBaseUrl(): string | undefined {
const configured = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_API_URL;
if (!configured || configured.trim() === "") {
if (process.env.NODE_ENV === "production") {
throw new Error(
"Missing BetterAuth base URL in production. Set BETTER_AUTH_URL (preferred) or NEXT_PUBLIC_API_URL."
);
}
return undefined;
}
let parsed: URL;
try {
parsed = new URL(configured);
} catch (urlError: unknown) {
const detail = urlError instanceof Error ? urlError.message : String(urlError);
throw new Error(
`BetterAuth base URL must be a valid URL. Current value: "${configured}". Parse error: ${detail}.`
);
}
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
throw new Error(
`BetterAuth base URL must use https in production. Current value: "${configured}".`
);
}
return parsed.origin;
}
/** /**
* Check if OIDC authentication is enabled via environment variable * Check if OIDC authentication is enabled via environment variable
@@ -92,54 +52,6 @@ export function validateOidcConfig(): void {
`The discovery URL is constructed by appending ".well-known/openid-configuration" to the issuer.` `The discovery URL is constructed by appending ".well-known/openid-configuration" to the issuer.`
); );
} }
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path
validateRedirectUri();
}
/**
* Validates the OIDC_REDIRECT_URI environment variable.
* - Must be a parseable URL
* - Path must start with /auth/oauth2/callback
* - Warns (but does not throw) if using localhost in production
*
* @throws Error if URL is invalid or path does not start with /auth/oauth2/callback
*/
function validateRedirectUri(): void {
const redirectUri = process.env.OIDC_REDIRECT_URI;
if (!redirectUri || redirectUri.trim() === "") {
// Already caught by REQUIRED_OIDC_ENV_VARS check above
return;
}
let parsed: URL;
try {
parsed = new URL(redirectUri);
} catch (urlError: unknown) {
const detail = urlError instanceof Error ? urlError.message : String(urlError);
throw new Error(
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
`Parse error: ${detail}. ` +
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
);
}
if (!parsed.pathname.startsWith("/auth/oauth2/callback")) {
throw new Error(
`OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` +
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
);
}
if (
process.env.NODE_ENV === "production" &&
(parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1")
) {
console.warn(
`[AUTH WARNING] OIDC_REDIRECT_URI uses localhost ("${redirectUri}") in production. ` +
`This is likely a misconfiguration. Use a public domain for production deployments.`
);
}
} }
/** /**
@@ -151,34 +63,14 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
return []; return [];
} }
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
const issuer = process.env.OIDC_ISSUER;
const redirectUri = process.env.OIDC_REDIRECT_URI;
if (!clientId) {
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
}
if (!clientSecret) {
throw new Error("OIDC_CLIENT_SECRET is required when OIDC is enabled but was not set.");
}
if (!issuer) {
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
}
if (!redirectUri) {
throw new Error("OIDC_REDIRECT_URI is required when OIDC is enabled but was not set.");
}
return [ return [
genericOAuth({ genericOAuth({
config: [ config: [
{ {
providerId: "authentik", providerId: "authentik",
clientId, clientId: process.env.OIDC_CLIENT_ID ?? "",
clientSecret, clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
discoveryUrl: `${issuer}.well-known/openid-configuration`, discoveryUrl: `${process.env.OIDC_ISSUER ?? ""}.well-known/openid-configuration`,
redirectURI: redirectUri,
pkce: true,
scopes: ["openid", "profile", "email"], scopes: ["openid", "profile", "email"],
}, },
], ],
@@ -186,95 +78,28 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
]; ];
} }
/**
* Build the list of trusted origins from environment variables.
*
* Sources (in order):
* - NEXT_PUBLIC_APP_URL — primary frontend URL
* - NEXT_PUBLIC_API_URL — API's own origin
* - TRUSTED_ORIGINS — comma-separated additional origins
* - localhost fallbacks — only when NODE_ENV !== "production"
*
* The returned list is deduplicated and empty strings are filtered out.
*/
export function getTrustedOrigins(): string[] {
const origins: string[] = [];
// Environment-driven origins
if (process.env.NEXT_PUBLIC_APP_URL) {
origins.push(process.env.NEXT_PUBLIC_APP_URL);
}
if (process.env.NEXT_PUBLIC_API_URL) {
origins.push(process.env.NEXT_PUBLIC_API_URL);
}
// Comma-separated additional origins (validated)
if (process.env.TRUSTED_ORIGINS) {
const rawOrigins = process.env.TRUSTED_ORIGINS.split(",")
.map((o) => o.trim())
.filter((o) => o !== "");
for (const origin of rawOrigins) {
try {
const parsed = new URL(origin);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
console.warn(`[AUTH] Ignoring non-HTTP origin in TRUSTED_ORIGINS: "${origin}"`);
continue;
}
origins.push(origin);
} catch (urlError: unknown) {
const detail = urlError instanceof Error ? urlError.message : String(urlError);
console.warn(`[AUTH] Ignoring invalid URL in TRUSTED_ORIGINS: "${origin}" (${detail})`);
}
}
}
// Localhost fallbacks for development only
if (process.env.NODE_ENV !== "production") {
origins.push("http://localhost:3000", "http://localhost:3001");
}
// Deduplicate and filter empty strings
return [...new Set(origins)].filter((o) => o !== "");
}
export function createAuth(prisma: PrismaClient) { export function createAuth(prisma: PrismaClient) {
// Validate OIDC configuration at startup - fail fast if misconfigured // Validate OIDC configuration at startup - fail fast if misconfigured
validateOidcConfig(); validateOidcConfig();
const baseURL = getBetterAuthBaseUrl();
return betterAuth({ return betterAuth({
baseURL,
basePath: "/auth",
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: "postgresql", provider: "postgresql",
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true, // Enable for now, can be disabled later
}, },
plugins: [...getOidcPlugins()], plugins: [...getOidcPlugins()],
logger: {
disabled: false,
level: "error",
},
session: { session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max expiresIn: 60 * 60 * 24, // 24 hours
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request updateAge: 60 * 60 * 24, // 24 hours
}, },
advanced: { trustedOrigins: [
database: { process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
// BetterAuth's default ID generator emits opaque strings; our auth tables use UUID PKs. "http://localhost:3001", // API origin (dev)
generateId: "uuid", "https://app.mosaicstack.dev", // Production web
}, "https://api.mosaicstack.dev", // Production API
defaultCookieAttributes: { ],
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
...(process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
},
},
trustedOrigins: getTrustedOrigins(),
}); });
} }

View File

@@ -1,41 +1,15 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock better-auth modules before importing AuthService (pulled in by AuthController)
vi.mock("better-auth/node", () => ({
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
}));
vi.mock("better-auth", () => ({
betterAuth: vi.fn().mockReturnValue({
handler: vi.fn(),
api: { getSession: vi.fn() },
}),
}));
vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: vi.fn().mockReturnValue({}),
}));
vi.mock("better-auth/plugins", () => ({
genericOAuth: vi.fn().mockReturnValue({ id: "generic-oauth" }),
}));
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { HttpException, HttpStatus, UnauthorizedException } from "@nestjs/common";
import type { AuthUser, AuthSession } from "@mosaic/shared"; import type { AuthUser, AuthSession } from "@mosaic/shared";
import type { Request as ExpressRequest, Response as ExpressResponse } from "express";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
describe("AuthController", () => { describe("AuthController", () => {
let controller: AuthController; let controller: AuthController;
let authService: AuthService;
const mockNodeHandler = vi.fn().mockResolvedValue(undefined);
const mockAuthService = { const mockAuthService = {
getAuth: vi.fn(), getAuth: vi.fn(),
getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler),
getAuthConfig: vi.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -50,239 +24,25 @@ describe("AuthController", () => {
}).compile(); }).compile();
controller = module.get<AuthController>(AuthController); controller = module.get<AuthController>(AuthController);
authService = module.get<AuthService>(AuthService);
vi.clearAllMocks(); vi.clearAllMocks();
// Restore mock implementations after clearAllMocks
mockAuthService.getNodeHandler.mockReturnValue(mockNodeHandler);
mockNodeHandler.mockResolvedValue(undefined);
}); });
describe("handleAuth", () => { describe("handleAuth", () => {
it("should delegate to BetterAuth node handler with Express req/res", async () => { it("should call BetterAuth handler", async () => {
const mockHandler = vi.fn().mockResolvedValue({ status: 200 });
mockAuthService.getAuth.mockReturnValue({ handler: mockHandler });
const mockRequest = { const mockRequest = {
method: "GET", method: "GET",
url: "/auth/session", url: "/auth/session",
headers: {},
ip: "127.0.0.1",
socket: { remoteAddress: "127.0.0.1" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
await controller.handleAuth(mockRequest, mockResponse);
expect(mockAuthService.getNodeHandler).toHaveBeenCalled();
expect(mockNodeHandler).toHaveBeenCalledWith(mockRequest, mockResponse);
});
it("should throw HttpException with 500 when handler throws before headers sent", async () => {
const handlerError = new Error("BetterAuth internal failure");
mockNodeHandler.mockRejectedValueOnce(handlerError);
const mockRequest = {
method: "POST",
url: "/auth/sign-in",
headers: {},
ip: "192.168.1.10",
socket: { remoteAddress: "192.168.1.10" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
try {
await controller.handleAuth(mockRequest, mockResponse);
// Should not reach here
expect.unreachable("Expected HttpException to be thrown");
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect((err as HttpException).getResponse()).toBe(
"Unable to complete authentication. Please try again in a moment."
);
}
});
it("should preserve better-call status and body for handler APIError", async () => {
const apiError = {
statusCode: HttpStatus.BAD_REQUEST,
message: "Invalid OAuth configuration",
body: {
message: "Invalid OAuth configuration",
code: "INVALID_OAUTH_CONFIGURATION",
},
};
mockNodeHandler.mockRejectedValueOnce(apiError);
const mockRequest = {
method: "POST",
url: "/auth/sign-in/oauth2",
headers: {},
ip: "192.168.1.10",
socket: { remoteAddress: "192.168.1.10" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
try {
await controller.handleAuth(mockRequest, mockResponse);
expect.unreachable("Expected HttpException to be thrown");
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
expect((err as HttpException).getResponse()).toMatchObject({
message: "Invalid OAuth configuration",
});
}
});
it("should log warning and not throw when handler throws after headers sent", async () => {
const handlerError = new Error("Stream interrupted");
mockNodeHandler.mockRejectedValueOnce(handlerError);
const mockRequest = {
method: "POST",
url: "/auth/sign-up",
headers: {},
ip: "10.0.0.5",
socket: { remoteAddress: "10.0.0.5" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: true,
} as unknown as ExpressResponse;
// Should not throw when headers already sent
await expect(controller.handleAuth(mockRequest, mockResponse)).resolves.toBeUndefined();
});
it("should handle non-Error thrown values", async () => {
mockNodeHandler.mockRejectedValueOnce("string error");
const mockRequest = {
method: "GET",
url: "/auth/callback",
headers: {},
ip: "127.0.0.1",
socket: { remoteAddress: "127.0.0.1" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
});
});
describe("getConfig", () => {
it("should return auth config from service", async () => {
const mockConfig = {
providers: [
{ id: "email", name: "Email", type: "credentials" as const },
{ id: "authentik", name: "Authentik", type: "oauth" as const },
],
};
mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);
const result = await controller.getConfig();
expect(result).toEqual(mockConfig);
expect(mockAuthService.getAuthConfig).toHaveBeenCalled();
});
it("should return correct response shape with only email provider", async () => {
const mockConfig = {
providers: [{ id: "email", name: "Email", type: "credentials" as const }],
};
mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);
const result = await controller.getConfig();
expect(result).toEqual(mockConfig);
expect(result.providers).toHaveLength(1);
expect(result.providers[0]).toEqual({
id: "email",
name: "Email",
type: "credentials",
});
});
it("should never leak secrets in auth config response", async () => {
// Set ALL sensitive environment variables with known values
const sensitiveEnv: Record<string, string> = {
OIDC_CLIENT_SECRET: "test-client-secret",
OIDC_CLIENT_ID: "test-client-id",
OIDC_ISSUER: "https://auth.test.com/",
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
BETTER_AUTH_SECRET: "test-better-auth-secret",
JWT_SECRET: "test-jwt-secret",
CSRF_SECRET: "test-csrf-secret",
DATABASE_URL: "postgresql://user:password@localhost/db",
OIDC_ENABLED: "true",
}; };
const originalEnv: Record<string, string | undefined> = {}; await controller.handleAuth(mockRequest as unknown as Request);
for (const [key, value] of Object.entries(sensitiveEnv)) {
originalEnv[key] = process.env[key];
process.env[key] = value;
}
try { expect(mockAuthService.getAuth).toHaveBeenCalled();
// Mock the service to return a realistic config with both providers expect(mockHandler).toHaveBeenCalledWith(mockRequest);
const mockConfig = {
providers: [
{ id: "email", name: "Email", type: "credentials" as const },
{ id: "authentik", name: "Authentik", type: "oauth" as const },
],
};
mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);
const result = await controller.getConfig();
const serialized = JSON.stringify(result);
// Assert no secret values leak into the serialized response
const forbiddenPatterns = [
"test-client-secret",
"test-client-id",
"test-better-auth-secret",
"test-jwt-secret",
"test-csrf-secret",
"auth.test.com",
"callback",
"password",
];
for (const pattern of forbiddenPatterns) {
expect(serialized).not.toContain(pattern);
}
// Assert response contains ONLY expected fields
expect(result).toHaveProperty("providers");
expect(Object.keys(result)).toEqual(["providers"]);
expect(Array.isArray(result.providers)).toBe(true);
for (const provider of result.providers) {
const keys = Object.keys(provider);
expect(keys).toEqual(expect.arrayContaining(["id", "name", "type"]));
expect(keys).toHaveLength(3);
}
} finally {
// Restore original environment
for (const [key] of Object.entries(sensitiveEnv)) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
}
}); });
}); });
@@ -320,22 +80,19 @@ describe("AuthController", () => {
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it("should throw UnauthorizedException when req.user is undefined", () => { it("should throw error if user not found in request", () => {
const mockRequest = { const mockRequest = {
session: { session: {
id: "session-123", id: "session-123",
token: "session-token", token: "session-token",
expiresAt: new Date(Date.now() + 86400000), expiresAt: new Date(),
}, },
}; };
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException); expect(() => controller.getSession(mockRequest)).toThrow("User session not found");
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context"
);
}); });
it("should throw UnauthorizedException when req.session is undefined", () => { it("should throw error if session not found in request", () => {
const mockRequest = { const mockRequest = {
user: { user: {
id: "user-123", id: "user-123",
@@ -344,30 +101,21 @@ describe("AuthController", () => {
}, },
}; };
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException); expect(() => controller.getSession(mockRequest)).toThrow("User session not found");
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context"
);
});
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
const mockRequest = {};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context"
);
}); });
}); });
describe("getProfile", () => { describe("getProfile", () => {
it("should return complete user profile with identity fields", () => { it("should return complete user profile with workspace fields", () => {
const mockUser: AuthUser = { const mockUser: AuthUser = {
id: "user-123", id: "user-123",
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
image: "https://example.com/avatar.jpg", image: "https://example.com/avatar.jpg",
emailVerified: true, emailVerified: true,
workspaceId: "workspace-123",
currentWorkspaceId: "workspace-456",
workspaceRole: "admin",
}; };
const result = controller.getProfile(mockUser); const result = controller.getProfile(mockUser);
@@ -378,10 +126,13 @@ describe("AuthController", () => {
name: mockUser.name, name: mockUser.name,
image: mockUser.image, image: mockUser.image,
emailVerified: mockUser.emailVerified, emailVerified: mockUser.emailVerified,
workspaceId: mockUser.workspaceId,
currentWorkspaceId: mockUser.currentWorkspaceId,
workspaceRole: mockUser.workspaceRole,
}); });
}); });
it("should return user profile with only required fields", () => { it("should return user profile with optional fields undefined", () => {
const mockUser: AuthUser = { const mockUser: AuthUser = {
id: "user-123", id: "user-123",
email: "test@example.com", email: "test@example.com",
@@ -394,96 +145,12 @@ describe("AuthController", () => {
id: mockUser.id, id: mockUser.id,
email: mockUser.email, email: mockUser.email,
name: mockUser.name, name: mockUser.name,
image: undefined,
emailVerified: undefined,
workspaceId: undefined,
currentWorkspaceId: undefined,
workspaceRole: undefined,
}); });
// Workspace fields are not included — served by GET /api/workspaces
expect(result).not.toHaveProperty("workspaceId");
expect(result).not.toHaveProperty("currentWorkspaceId");
expect(result).not.toHaveProperty("workspaceRole");
});
});
describe("getClientIp (via handleAuth)", () => {
it("should extract IP from X-Forwarded-For with single IP", async () => {
const mockRequest = {
method: "GET",
url: "/auth/callback",
headers: { "x-forwarded-for": "203.0.113.50" },
ip: "127.0.0.1",
socket: { remoteAddress: "127.0.0.1" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
// Spy on the logger to verify the extracted IP
const debugSpy = vi.spyOn(controller["logger"], "debug");
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
});
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
const mockRequest = {
method: "GET",
url: "/auth/callback",
headers: { "x-forwarded-for": "203.0.113.50, 70.41.3.18" },
ip: "127.0.0.1",
socket: { remoteAddress: "127.0.0.1" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
const debugSpy = vi.spyOn(controller["logger"], "debug");
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
// Ensure it does NOT contain the second IP in the extracted position
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
});
it("should extract first IP from X-Forwarded-For as array", async () => {
const mockRequest = {
method: "GET",
url: "/auth/callback",
headers: { "x-forwarded-for": ["203.0.113.50", "70.41.3.18"] },
ip: "127.0.0.1",
socket: { remoteAddress: "127.0.0.1" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
const debugSpy = vi.spyOn(controller["logger"], "debug");
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
});
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
const mockRequest = {
method: "GET",
url: "/auth/callback",
headers: {},
ip: "192.168.1.100",
socket: { remoteAddress: "192.168.1.100" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
const debugSpy = vi.spyOn(controller["logger"], "debug");
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
}); });
}); });
}); });

View File

@@ -1,25 +1,19 @@
import { import { Controller, All, Req, Get, UseGuards, Request, Logger } from "@nestjs/common";
Controller,
All,
Req,
Res,
Get,
Header,
UseGuards,
Request,
Logger,
HttpException,
HttpStatus,
UnauthorizedException,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import type { Request as ExpressRequest, Response as ExpressResponse } from "express"; import type { AuthUser, AuthSession } from "@mosaic/shared";
import type { AuthUser, AuthSession, AuthConfigResponse } from "@mosaic/shared";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthGuard } from "./guards/auth.guard"; import { AuthGuard } from "./guards/auth.guard";
import { CurrentUser } from "./decorators/current-user.decorator"; import { CurrentUser } from "./decorators/current-user.decorator";
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
import type { AuthenticatedRequest } from "./types/better-auth-request.interface"; interface RequestWithSession {
user?: AuthUser;
session?: {
id: string;
token: string;
expiresAt: Date;
[key: string]: unknown;
};
}
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
@@ -33,13 +27,10 @@ export class AuthController {
*/ */
@Get("session") @Get("session")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
getSession(@Request() req: AuthenticatedRequest): AuthSession { getSession(@Request() req: RequestWithSession): AuthSession {
// Defense-in-depth: AuthGuard should guarantee these, but if someone adds
// a route with AuthenticatedRequest and forgets @UseGuards(AuthGuard),
// TypeScript types won't help at runtime.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!req.user || !req.session) { if (!req.user || !req.session) {
throw new UnauthorizedException("Missing authentication context"); // This should never happen after AuthGuard, but TypeScript needs the check
throw new Error("User session not found");
} }
return { return {
@@ -72,25 +63,19 @@ export class AuthController {
if (user.emailVerified !== undefined) { if (user.emailVerified !== undefined) {
profile.emailVerified = user.emailVerified; profile.emailVerified = user.emailVerified;
} }
if (user.workspaceId !== undefined) {
// Workspace context is served by GET /api/workspaces, not the auth profile. profile.workspaceId = user.workspaceId;
// The deprecated workspaceId/currentWorkspaceId/workspaceRole fields on }
// AuthUser are never populated by BetterAuth and are omitted here. if (user.currentWorkspaceId !== undefined) {
profile.currentWorkspaceId = user.currentWorkspaceId;
}
if (user.workspaceRole !== undefined) {
profile.workspaceRole = user.workspaceRole;
}
return profile; return profile;
} }
/**
* Get available authentication providers.
* Public endpoint (no auth guard) so the frontend can discover login options
* before the user is authenticated.
*/
@Get("config")
@Header("Cache-Control", "public, max-age=300")
async getConfig(): Promise<AuthConfigResponse> {
return this.authService.getAuthConfig();
}
/** /**
* Handle all other auth routes (sign-in, sign-up, sign-out, etc.) * Handle all other auth routes (sign-in, sign-up, sign-out, etc.)
* Delegates to BetterAuth * Delegates to BetterAuth
@@ -102,110 +87,38 @@ export class AuthController {
* Rate limiting and logging are applied to mitigate abuse (SEC-API-10). * Rate limiting and logging are applied to mitigate abuse (SEC-API-10).
*/ */
@All("*") @All("*")
// BetterAuth handles CSRF internally (Fetch Metadata + SameSite=Lax cookies).
// @SkipCsrf avoids double-protection conflicts.
// See: https://www.better-auth.com/docs/reference/security
@SkipCsrf()
@Throttle({ strict: { limit: 10, ttl: 60000 } }) @Throttle({ strict: { limit: 10, ttl: 60000 } })
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> { async handleAuth(@Req() req: Request): Promise<unknown> {
// Extract client IP for logging // Extract client IP for logging
const clientIp = this.getClientIp(req); const clientIp = this.getClientIp(req);
const requestPath = (req as unknown as { url?: string }).url ?? "unknown";
const method = (req as unknown as { method?: string }).method ?? "UNKNOWN";
// Log auth catch-all hits for monitoring and debugging // Log auth catch-all hits for monitoring and debugging
this.logger.debug(`Auth catch-all: ${req.method} ${req.url} from ${clientIp}`); this.logger.debug(`Auth catch-all: ${method} ${requestPath} from ${clientIp}`);
const handler = this.authService.getNodeHandler(); const auth = this.authService.getAuth();
return auth.handler(req);
try {
await handler(req, res);
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
if (res.statusCode >= 500) {
this.logger.error(
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
` — check container stdout for '# SERVER_ERROR' details`
);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error(
`BetterAuth handler error: ${req.method} ${req.url} from ${clientIp} - ${message}`,
stack
);
if (!res.headersSent) {
const mappedError = this.mapToHttpException(error);
if (mappedError) {
throw mappedError;
}
throw new HttpException(
"Unable to complete authentication. Please try again in a moment.",
HttpStatus.INTERNAL_SERVER_ERROR
);
}
this.logger.error(
`Headers already sent for failed auth request ${req.method} ${req.url} — client may have received partial response`
);
}
} }
/** /**
* Extract client IP from request, handling proxies * Extract client IP from request, handling proxies
*/ */
private getClientIp(req: ExpressRequest): string { private getClientIp(req: Request): string {
const reqWithHeaders = req as unknown as {
headers?: Record<string, string | string[] | undefined>;
ip?: string;
socket?: { remoteAddress?: string };
};
// Check X-Forwarded-For header (for reverse proxy setups) // Check X-Forwarded-For header (for reverse proxy setups)
const forwardedFor = req.headers["x-forwarded-for"]; const forwardedFor = reqWithHeaders.headers?.["x-forwarded-for"];
if (forwardedFor) { if (forwardedFor) {
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
return ips?.split(",")[0]?.trim() ?? "unknown"; return ips?.split(",")[0]?.trim() ?? "unknown";
} }
// Fall back to direct IP // Fall back to direct IP
return req.ip ?? req.socket.remoteAddress ?? "unknown"; return reqWithHeaders.ip ?? reqWithHeaders.socket?.remoteAddress ?? "unknown";
}
/**
* Preserve known HTTP errors from BetterAuth/better-call instead of converting
* every failure into a generic 500.
*/
private mapToHttpException(error: unknown): HttpException | null {
if (error instanceof HttpException) {
return error;
}
if (!error || typeof error !== "object") {
return null;
}
const statusCode = "statusCode" in error ? error.statusCode : undefined;
if (!this.isHttpStatus(statusCode)) {
return null;
}
const responseBody = "body" in error && error.body !== undefined ? error.body : undefined;
if (
responseBody !== undefined &&
responseBody !== null &&
(typeof responseBody === "string" || typeof responseBody === "object")
) {
return new HttpException(responseBody, statusCode);
}
const message =
"message" in error && typeof error.message === "string" && error.message.length > 0
? error.message
: "Authentication request failed";
return new HttpException(message, statusCode);
}
private isHttpStatus(value: unknown): value is number {
if (typeof value !== "number" || !Number.isInteger(value)) {
return false;
}
return value >= 400 && value <= 599;
} }
} }

View File

@@ -3,14 +3,11 @@ import { PrismaModule } from "../prisma/prisma.module";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthGuard } from "./guards/auth.guard"; import { AuthGuard } from "./guards/auth.guard";
import { LocalAuthController } from "./local/local-auth.controller";
import { LocalAuthService } from "./local/local-auth.service";
import { LocalAuthEnabledGuard } from "./local/local-auth.guard";
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
controllers: [AuthController, LocalAuthController], controllers: [AuthController],
providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard], providers: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard], exports: [AuthService, AuthGuard],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -23,17 +23,10 @@ describe("AuthController - Rate Limiting", () => {
let app: INestApplication; let app: INestApplication;
let loggerSpy: ReturnType<typeof vi.spyOn>; let loggerSpy: ReturnType<typeof vi.spyOn>;
const mockNodeHandler = vi.fn(
(_req: unknown, res: { statusCode: number; end: (body: string) => void }) => {
res.statusCode = 200;
res.end(JSON.stringify({}));
return Promise.resolve();
}
);
const mockAuthService = { const mockAuthService = {
getAuth: vi.fn(), getAuth: vi.fn().mockReturnValue({
getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler), handler: vi.fn().mockResolvedValue({ status: 200, body: {} }),
}),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -83,7 +76,7 @@ describe("AuthController - Rate Limiting", () => {
expect(response.status).not.toBe(HttpStatus.TOO_MANY_REQUESTS); expect(response.status).not.toBe(HttpStatus.TOO_MANY_REQUESTS);
} }
expect(mockAuthService.getNodeHandler).toHaveBeenCalledTimes(3); expect(mockAuthService.getAuth).toHaveBeenCalledTimes(3);
}); });
it("should return 429 when rate limit is exceeded", async () => { it("should return 429 when rate limit is exceeded", async () => {

View File

@@ -1,26 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
// Mock better-auth modules before importing AuthService
vi.mock("better-auth/node", () => ({
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
}));
vi.mock("better-auth", () => ({
betterAuth: vi.fn().mockReturnValue({
handler: vi.fn(),
api: { getSession: vi.fn() },
}),
}));
vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: vi.fn().mockReturnValue({}),
}));
vi.mock("better-auth/plugins", () => ({
genericOAuth: vi.fn().mockReturnValue({ id: "generic-oauth" }),
}));
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
@@ -51,12 +30,6 @@ describe("AuthService", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => {
vi.restoreAllMocks();
delete process.env.OIDC_ENABLED;
delete process.env.OIDC_ISSUER;
});
describe("getAuth", () => { describe("getAuth", () => {
it("should return BetterAuth instance", () => { it("should return BetterAuth instance", () => {
const auth = service.getAuth(); const auth = service.getAuth();
@@ -89,23 +62,6 @@ describe("AuthService", () => {
}, },
}); });
}); });
it("should return null when user is not found", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const result = await service.getUserById("nonexistent-id");
expect(result).toBeNull();
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { id: "nonexistent-id" },
select: {
id: true,
email: true,
name: true,
authProviderId: true,
},
});
});
}); });
describe("getUserByEmail", () => { describe("getUserByEmail", () => {
@@ -132,269 +88,6 @@ describe("AuthService", () => {
}, },
}); });
}); });
it("should return null when user is not found", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const result = await service.getUserByEmail("unknown@example.com");
expect(result).toBeNull();
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { email: "unknown@example.com" },
select: {
id: true,
email: true,
name: true,
authProviderId: true,
},
});
});
});
describe("isOidcProviderReachable", () => {
const discoveryUrl = "https://auth.example.com/.well-known/openid-configuration";
beforeEach(() => {
process.env.OIDC_ISSUER = "https://auth.example.com/";
// Reset the cache by accessing private fields via bracket notation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthResult = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).consecutiveHealthFailures = 0;
});
it("should return true when discovery URL returns 200", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
vi.stubGlobal("fetch", mockFetch);
const result = await service.isOidcProviderReachable();
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(discoveryUrl, {
signal: expect.any(AbortSignal) as AbortSignal,
});
});
it("should return false on network error", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
vi.stubGlobal("fetch", mockFetch);
const result = await service.isOidcProviderReachable();
expect(result).toBe(false);
});
it("should return false on timeout", async () => {
const mockFetch = vi.fn().mockRejectedValue(new DOMException("The operation was aborted"));
vi.stubGlobal("fetch", mockFetch);
const result = await service.isOidcProviderReachable();
expect(result).toBe(false);
});
it("should return false when discovery URL returns non-200", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
vi.stubGlobal("fetch", mockFetch);
const result = await service.isOidcProviderReachable();
expect(result).toBe(false);
});
it("should cache result for 30 seconds", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
vi.stubGlobal("fetch", mockFetch);
// First call - fetches
const result1 = await service.isOidcProviderReachable();
expect(result1).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Second call within 30s - uses cache
const result2 = await service.isOidcProviderReachable();
expect(result2).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1, no new fetch
// Simulate cache expiry by moving lastHealthCheck back
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = Date.now() - 31_000;
// Third call after cache expiry - fetches again
const result3 = await service.isOidcProviderReachable();
expect(result3).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2); // Now 2
});
it("should cache false results too", async () => {
const mockFetch = vi
.fn()
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal("fetch", mockFetch);
// First call - fails
const result1 = await service.isOidcProviderReachable();
expect(result1).toBe(false);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Second call within 30s - returns cached false
const result2 = await service.isOidcProviderReachable();
expect(result2).toBe(false);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("should escalate to error level after 3 consecutive failures", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
vi.stubGlobal("fetch", mockFetch);
const loggerWarn = vi.spyOn(service["logger"], "warn");
const loggerError = vi.spyOn(service["logger"], "error");
// Failures 1 and 2 should log at warn level
await service.isOidcProviderReachable();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0; // Reset cache
await service.isOidcProviderReachable();
expect(loggerWarn).toHaveBeenCalledTimes(2);
expect(loggerError).not.toHaveBeenCalled();
// Failure 3 should escalate to error level
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0;
await service.isOidcProviderReachable();
expect(loggerError).toHaveBeenCalledTimes(1);
expect(loggerError).toHaveBeenCalledWith(
expect.stringContaining("OIDC provider unreachable")
);
});
it("should escalate to error level after 3 consecutive non-OK responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
vi.stubGlobal("fetch", mockFetch);
const loggerWarn = vi.spyOn(service["logger"], "warn");
const loggerError = vi.spyOn(service["logger"], "error");
// Failures 1 and 2 at warn level
await service.isOidcProviderReachable();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0;
await service.isOidcProviderReachable();
expect(loggerWarn).toHaveBeenCalledTimes(2);
expect(loggerError).not.toHaveBeenCalled();
// Failure 3 at error level
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0;
await service.isOidcProviderReachable();
expect(loggerError).toHaveBeenCalledTimes(1);
expect(loggerError).toHaveBeenCalledWith(
expect.stringContaining("OIDC provider returned non-OK status")
);
});
it("should reset failure counter and log recovery on success after failures", async () => {
const mockFetch = vi
.fn()
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal("fetch", mockFetch);
const loggerLog = vi.spyOn(service["logger"], "log");
// Two failures
await service.isOidcProviderReachable();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0;
await service.isOidcProviderReachable();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0;
// Recovery
const result = await service.isOidcProviderReachable();
expect(result).toBe(true);
expect(loggerLog).toHaveBeenCalledWith(
expect.stringContaining("OIDC provider recovered after 2 consecutive failure(s)")
);
// Verify counter reset
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((service as any).consecutiveHealthFailures).toBe(0);
});
});
describe("getAuthConfig", () => {
it("should return only email provider when OIDC is disabled", async () => {
delete process.env.OIDC_ENABLED;
const result = await service.getAuthConfig();
expect(result).toEqual({
providers: [{ id: "email", name: "Email", type: "credentials" }],
});
});
it("should return both email and authentik providers when OIDC is enabled and reachable", async () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/";
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", mockFetch);
const result = await service.getAuthConfig();
expect(result).toEqual({
providers: [
{ id: "email", name: "Email", type: "credentials" },
{ id: "authentik", name: "Authentik", type: "oauth" },
],
});
});
it("should return only email provider when OIDC_ENABLED is false", async () => {
process.env.OIDC_ENABLED = "false";
const result = await service.getAuthConfig();
expect(result).toEqual({
providers: [{ id: "email", name: "Email", type: "credentials" }],
});
});
it("should omit authentik when OIDC is enabled but provider is unreachable", async () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/";
// Reset cache
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).lastHealthCheck = 0;
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
vi.stubGlobal("fetch", mockFetch);
const result = await service.getAuthConfig();
expect(result).toEqual({
providers: [{ id: "email", name: "Email", type: "credentials" }],
});
});
}); });
describe("verifySession", () => { describe("verifySession", () => {
@@ -410,7 +103,7 @@ describe("AuthService", () => {
}, },
}; };
it("should validate session token using secure BetterAuth cookie header", async () => { it("should return session data for valid token", async () => {
const auth = service.getAuth(); const auth = service.getAuth();
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData); const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
auth.api = { getSession: mockGetSession } as any; auth.api = { getSession: mockGetSession } as any;
@@ -418,58 +111,7 @@ describe("AuthService", () => {
const result = await service.verifySession("valid-token"); const result = await service.verifySession("valid-token");
expect(result).toEqual(mockSessionData); expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenCalledTimes(1);
expect(mockGetSession).toHaveBeenCalledWith({ expect(mockGetSession).toHaveBeenCalledWith({
headers: {
cookie: "__Secure-better-auth.session_token=valid-token",
},
});
});
it("should preserve raw cookie token value without URL re-encoding", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("tok/with+=chars=");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenCalledWith({
headers: {
cookie: "__Secure-better-auth.session_token=tok/with+=chars=",
},
});
});
it("should fall back to Authorization header when cookie-based lookups miss", async () => {
const auth = service.getAuth();
const mockGetSession = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("valid-token");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenNthCalledWith(1, {
headers: {
cookie: "__Secure-better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(2, {
headers: {
cookie: "better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(3, {
headers: {
cookie: "__Host-better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(4, {
headers: { headers: {
authorization: "Bearer valid-token", authorization: "Bearer valid-token",
}, },
@@ -486,264 +128,14 @@ describe("AuthService", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("should return null for 'invalid token' auth error", async () => { it("should return null and log error on verification failure", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid token provided"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("bad-token");
expect(result).toBeNull();
});
it("should return null for 'expired' auth error", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Token expired"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("expired-token");
expect(result).toBeNull();
});
it("should return null for 'session not found' auth error", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Session not found"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("missing-session");
expect(result).toBeNull();
});
it("should return null for 'unauthorized' auth error", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Unauthorized"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("unauth-token");
expect(result).toBeNull();
});
it("should return null for 'invalid session' auth error", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid session"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("invalid-session");
expect(result).toBeNull();
});
it("should return null for 'session expired' auth error", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Session expired"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("expired-session");
expect(result).toBeNull();
});
it("should return null for bare 'unauthorized' (exact match)", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("unauthorized"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("unauth-token");
expect(result).toBeNull();
});
it("should return null for bare 'expired' (exact match)", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("expired"));
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("expired-token");
expect(result).toBeNull();
});
it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired"));
auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired");
});
it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {
const auth = service.getAuth();
const mockGetSession = vi
.fn()
.mockRejectedValue(new Error("Unauthorized: Access denied for user"));
auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow(
"Unauthorized: Access denied for user"
);
});
it("should return null when a non-Error value is thrown", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue("string-error");
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("any-token");
expect(result).toBeNull();
});
it("should return null when getSession throws a non-Error value (string)", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue("some error");
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("any-token");
expect(result).toBeNull();
});
it("should return null when getSession throws a non-Error value (object)", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue({ code: "ERR_UNKNOWN" });
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("any-token");
expect(result).toBeNull();
});
it("should re-throw unexpected errors that are not known auth errors", async () => {
const auth = service.getAuth(); const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Verification failed")); const mockGetSession = vi.fn().mockRejectedValue(new Error("Verification failed"));
auth.api = { getSession: mockGetSession } as any; auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("error-token")).rejects.toThrow("Verification failed"); const result = await service.verifySession("error-token");
});
it("should re-throw Prisma infrastructure errors", async () => {
const auth = service.getAuth();
const prismaError = new Error("connect ECONNREFUSED 127.0.0.1:5432");
const mockGetSession = vi.fn().mockRejectedValue(prismaError);
auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow("ECONNREFUSED");
});
it("should re-throw timeout errors as infrastructure errors", async () => {
const auth = service.getAuth();
const timeoutError = new Error("Connection timeout after 5000ms");
const mockGetSession = vi.fn().mockRejectedValue(timeoutError);
auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow("timeout");
});
it("should re-throw errors with Prisma-prefixed constructor name", async () => {
const auth = service.getAuth();
class PrismaClientKnownRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "PrismaClientKnownRequestError";
}
}
const prismaError = new PrismaClientKnownRequestError("Database connection lost");
const mockGetSession = vi.fn().mockRejectedValue(prismaError);
auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow("Database connection lost");
});
it("should redact Bearer tokens from logged error messages", async () => {
const auth = service.getAuth();
const errorWithToken = new Error(
"Request failed: Bearer eyJhbGciOiJIUzI1NiJ9.secret-payload in header"
);
const mockGetSession = vi.fn().mockRejectedValue(errorWithToken);
auth.api = { getSession: mockGetSession } as any;
const loggerError = vi.spyOn(service["logger"], "error");
await expect(service.verifySession("any-token")).rejects.toThrow();
expect(loggerError).toHaveBeenCalledWith(
"Session verification failed due to unexpected error",
expect.stringContaining("Bearer [REDACTED]")
);
expect(loggerError).toHaveBeenCalledWith(
"Session verification failed due to unexpected error",
expect.not.stringContaining("eyJhbGciOiJIUzI1NiJ9")
);
});
it("should redact Bearer tokens from error stack traces", async () => {
const auth = service.getAuth();
const errorWithToken = new Error("Something went wrong");
errorWithToken.stack =
"Error: Something went wrong\n at fetch (Bearer abc123-secret-token)\n at verifySession";
const mockGetSession = vi.fn().mockRejectedValue(errorWithToken);
auth.api = { getSession: mockGetSession } as any;
const loggerError = vi.spyOn(service["logger"], "error");
await expect(service.verifySession("any-token")).rejects.toThrow();
expect(loggerError).toHaveBeenCalledWith(
"Session verification failed due to unexpected error",
expect.stringContaining("Bearer [REDACTED]")
);
expect(loggerError).toHaveBeenCalledWith(
"Session verification failed due to unexpected error",
expect.not.stringContaining("abc123-secret-token")
);
});
it("should warn when a non-Error string value is thrown", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue("string-error");
auth.api = { getSession: mockGetSession } as any;
const loggerWarn = vi.spyOn(service["logger"], "warn");
const result = await service.verifySession("any-token");
expect(result).toBeNull(); expect(result).toBeNull();
expect(loggerWarn).toHaveBeenCalledWith(
"Session verification received non-Error thrown value",
"string-error"
);
});
it("should warn with JSON when a non-Error object is thrown", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue({ code: "ERR_UNKNOWN" });
auth.api = { getSession: mockGetSession } as any;
const loggerWarn = vi.spyOn(service["logger"], "warn");
const result = await service.verifySession("any-token");
expect(result).toBeNull();
expect(loggerWarn).toHaveBeenCalledWith(
"Session verification received non-Error thrown value",
JSON.stringify({ code: "ERR_UNKNOWN" })
);
});
it("should not warn for expected auth errors (Error instances)", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid token provided"));
auth.api = { getSession: mockGetSession } as any;
const loggerWarn = vi.spyOn(service["logger"], "warn");
const result = await service.verifySession("bad-token");
expect(result).toBeNull();
expect(loggerWarn).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -1,49 +1,17 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { IncomingMessage, ServerResponse } from "http";
import { toNodeHandler } from "better-auth/node";
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { createAuth, isOidcEnabled, type Auth } from "./auth.config"; import { createAuth, type Auth } from "./auth.config";
/** Duration in milliseconds to cache the OIDC health check result */
const OIDC_HEALTH_CACHE_TTL_MS = 30_000;
/** Timeout in milliseconds for the OIDC discovery URL fetch */
const OIDC_HEALTH_TIMEOUT_MS = 2_000;
/** Number of consecutive health-check failures before escalating to error level */
const HEALTH_ESCALATION_THRESHOLD = 3;
/** Verified session shape returned by BetterAuth's getSession */
interface VerifiedSession {
user: Record<string, unknown>;
session: Record<string, unknown>;
}
interface SessionHeaderCandidate {
headers: Record<string, string>;
}
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
private readonly auth: Auth; private readonly auth: Auth;
private readonly nodeHandler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
/** Timestamp of the last OIDC health check */
private lastHealthCheck = 0;
/** Cached result of the last OIDC health check */
private lastHealthResult = false;
/** Consecutive OIDC health check failure count for log-level escalation */
private consecutiveHealthFailures = 0;
constructor(private readonly prisma: PrismaService) { constructor(private readonly prisma: PrismaService) {
// PrismaService extends PrismaClient and is compatible with BetterAuth's adapter // PrismaService extends PrismaClient and is compatible with BetterAuth's adapter
// Cast is safe as PrismaService provides all required PrismaClient methods // Cast is safe as PrismaService provides all required PrismaClient methods
// TODO(#411): BetterAuth returns opaque types — replace when upstream exports typed interfaces
this.auth = createAuth(this.prisma as unknown as PrismaClient); this.auth = createAuth(this.prisma as unknown as PrismaClient);
this.nodeHandler = toNodeHandler(this.auth);
} }
/** /**
@@ -53,14 +21,6 @@ export class AuthService {
return this.auth; return this.auth;
} }
/**
* Get Node.js-compatible request handler for BetterAuth.
* Wraps BetterAuth's Web API handler to work with Express/Node.js req/res.
*/
getNodeHandler(): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
return this.nodeHandler;
}
/** /**
* Get user by ID * Get user by ID
*/ */
@@ -103,159 +63,32 @@ export class AuthService {
/** /**
* Verify session token * Verify session token
* Returns session data if valid, null if invalid or expired. * Returns session data if valid, null if invalid or expired
* Only known-safe auth errors return null; everything else propagates as 500.
*/ */
async verifySession(token: string): Promise<VerifiedSession | null> { async verifySession(
let sawNonError = false; token: string
): Promise<{ user: Record<string, unknown>; session: Record<string, unknown> } | null> {
for (const candidate of this.buildSessionHeaderCandidates(token)) { try {
try { const session = await this.auth.api.getSession({
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
const session = await this.auth.api.getSession(candidate);
if (!session) {
continue;
}
return {
user: session.user as Record<string, unknown>,
session: session.session as Record<string, unknown>,
};
} catch (error: unknown) {
if (error instanceof Error) {
if (this.isExpectedAuthError(error.message)) {
continue;
}
// Infrastructure or unexpected — propagate as 500
const safeMessage = (error.stack ?? error.message).replace(
/Bearer\s+\S+/gi,
"Bearer [REDACTED]"
);
this.logger.error("Session verification failed due to unexpected error", safeMessage);
throw error;
}
// Non-Error thrown values — log once for observability, treat as auth failure
if (!sawNonError) {
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
sawNonError = true;
}
}
}
return null;
}
private buildSessionHeaderCandidates(token: string): SessionHeaderCandidate[] {
return [
{
headers: {
cookie: `__Secure-better-auth.session_token=${token}`,
},
},
{
headers: {
cookie: `better-auth.session_token=${token}`,
},
},
{
headers: {
cookie: `__Host-better-auth.session_token=${token}`,
},
},
{
headers: { headers: {
authorization: `Bearer ${token}`, authorization: `Bearer ${token}`,
}, },
},
];
}
private isExpectedAuthError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("invalid token") ||
normalized.includes("token expired") ||
normalized.includes("session expired") ||
normalized.includes("session not found") ||
normalized.includes("invalid session") ||
normalized === "unauthorized" ||
normalized === "expired"
);
}
/**
* Check if the OIDC provider (Authentik) is reachable by fetching the discovery URL.
* Results are cached for 30 seconds to prevent repeated network calls.
*
* @returns true if the provider responds with an HTTP 2xx status, false otherwise
*/
async isOidcProviderReachable(): Promise<boolean> {
const now = Date.now();
// Return cached result if still valid
if (now - this.lastHealthCheck < OIDC_HEALTH_CACHE_TTL_MS) {
this.logger.debug("OIDC health check: returning cached result");
return this.lastHealthResult;
}
const discoveryUrl = `${process.env.OIDC_ISSUER ?? ""}.well-known/openid-configuration`;
this.logger.debug(`OIDC health check: fetching ${discoveryUrl}`);
try {
const response = await fetch(discoveryUrl, {
signal: AbortSignal.timeout(OIDC_HEALTH_TIMEOUT_MS),
}); });
this.lastHealthCheck = Date.now(); if (!session) {
this.lastHealthResult = response.ok; return null;
if (response.ok) {
if (this.consecutiveHealthFailures > 0) {
this.logger.log(
`OIDC provider recovered after ${String(this.consecutiveHealthFailures)} consecutive failure(s)`
);
}
this.consecutiveHealthFailures = 0;
} else {
this.consecutiveHealthFailures++;
const logLevel =
this.consecutiveHealthFailures >= HEALTH_ESCALATION_THRESHOLD ? "error" : "warn";
this.logger[logLevel](
`OIDC provider returned non-OK status: ${String(response.status)} from ${discoveryUrl}`
);
} }
return this.lastHealthResult; return {
} catch (error: unknown) { user: session.user as Record<string, unknown>,
this.lastHealthCheck = Date.now(); session: session.session as Record<string, unknown>,
this.lastHealthResult = false; };
this.consecutiveHealthFailures++; } catch (error) {
this.logger.error(
const message = error instanceof Error ? error.message : String(error); "Session verification failed",
const logLevel = error instanceof Error ? error.message : "Unknown error"
this.consecutiveHealthFailures >= HEALTH_ESCALATION_THRESHOLD ? "error" : "warn"; );
this.logger[logLevel](`OIDC provider unreachable at ${discoveryUrl}: ${message}`); return null;
return false;
} }
} }
/**
* Get authentication configuration for the frontend.
* Returns available auth providers so the UI can render login options dynamically.
* When OIDC is enabled, performs a health check to verify the provider is reachable.
*/
async getAuthConfig(): Promise<AuthConfigResponse> {
const providers: AuthProviderConfig[] = [{ id: "email", name: "Email", type: "credentials" }];
if (isOidcEnabled() && (await this.isOidcProviderReachable())) {
providers.push({ id: "authentik", name: "Authentik", type: "oauth" });
}
return { providers };
}
} }

View File

@@ -1,13 +1,14 @@
import type { ExecutionContext } from "@nestjs/common"; import type { ExecutionContext } from "@nestjs/common";
import { createParamDecorator, UnauthorizedException } from "@nestjs/common"; import { createParamDecorator, UnauthorizedException } from "@nestjs/common";
import type { AuthUser } from "@mosaic/shared"; import type { AuthUser } from "@mosaic/shared";
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
interface RequestWithUser {
user?: AuthUser;
}
export const CurrentUser = createParamDecorator( export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => { (_data: unknown, ctx: ExecutionContext): AuthUser => {
// Use MaybeAuthenticatedRequest because the decorator doesn't know const request = ctx.switchToHttp().getRequest<RequestWithUser>();
// whether AuthGuard ran — the null check provides defense-in-depth.
const request = ctx.switchToHttp().getRequest<MaybeAuthenticatedRequest>();
if (!request.user) { if (!request.user) {
throw new UnauthorizedException("No authenticated user found on request"); throw new UnauthorizedException("No authenticated user found on request");
} }

View File

@@ -1,39 +1,30 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
// Mock better-auth modules before importing AuthGuard (which imports AuthService)
vi.mock("better-auth/node", () => ({
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
}));
vi.mock("better-auth", () => ({
betterAuth: vi.fn().mockReturnValue({
handler: vi.fn(),
api: { getSession: vi.fn() },
}),
}));
vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: vi.fn().mockReturnValue({}),
}));
vi.mock("better-auth/plugins", () => ({
genericOAuth: vi.fn().mockReturnValue({ id: "generic-oauth" }),
}));
import { AuthGuard } from "./auth.guard"; import { AuthGuard } from "./auth.guard";
import type { AuthService } from "../auth.service"; import { AuthService } from "../auth.service";
describe("AuthGuard", () => { describe("AuthGuard", () => {
let guard: AuthGuard; let guard: AuthGuard;
let authService: AuthService;
const mockAuthService = { const mockAuthService = {
verifySession: vi.fn(), verifySession: vi.fn(),
}; };
beforeEach(() => { beforeEach(async () => {
// Directly construct the guard with the mock to avoid NestJS DI issues const module: TestingModule = await Test.createTestingModule({
guard = new AuthGuard(mockAuthService as unknown as AuthService); providers: [
AuthGuard,
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
guard = module.get<AuthGuard>(AuthGuard);
authService = module.get<AuthService>(AuthService);
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -156,134 +147,17 @@ describe("AuthGuard", () => {
); );
}); });
it("should propagate non-auth errors as-is (not wrap as 401)", async () => { it("should throw UnauthorizedException if session verification fails", async () => {
const infraError = new Error("connect ECONNREFUSED 127.0.0.1:5432"); mockAuthService.verifySession.mockRejectedValue(new Error("Verification failed"));
mockAuthService.verifySession.mockRejectedValue(infraError);
const context = createMockExecutionContext({ const context = createMockExecutionContext({
authorization: "Bearer error-token", authorization: "Bearer error-token",
}); });
await expect(guard.canActivate(context)).rejects.toThrow(infraError);
await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(UnauthorizedException);
});
it("should propagate database errors so GlobalExceptionFilter returns 500", async () => {
const dbError = new Error("PrismaClientKnownRequestError: Connection refused");
mockAuthService.verifySession.mockRejectedValue(dbError);
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(dbError);
await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(UnauthorizedException);
});
it("should propagate timeout errors so GlobalExceptionFilter returns 503", async () => {
const timeoutError = new Error("Connection timeout after 5000ms");
mockAuthService.verifySession.mockRejectedValue(timeoutError);
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(timeoutError);
await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(UnauthorizedException);
});
});
describe("user data validation", () => {
const mockSession = {
id: "session-123",
token: "session-token",
expiresAt: new Date(Date.now() + 86400000),
};
it("should throw UnauthorizedException when user is missing id", async () => {
mockAuthService.verifySession.mockResolvedValue({
user: { email: "a@b.com", name: "Test" },
session: mockSession,
});
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow( await expect(guard.canActivate(context)).rejects.toThrow("Authentication failed");
"Invalid user data in session"
);
}); });
it("should throw UnauthorizedException when user is missing email", async () => {
mockAuthService.verifySession.mockResolvedValue({
user: { id: "1", name: "Test" },
session: mockSession,
});
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow(
"Invalid user data in session"
);
});
it("should throw UnauthorizedException when user is missing name", async () => {
mockAuthService.verifySession.mockResolvedValue({
user: { id: "1", email: "a@b.com" },
session: mockSession,
});
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow(
"Invalid user data in session"
);
});
it("should throw UnauthorizedException when user is a string", async () => {
mockAuthService.verifySession.mockResolvedValue({
user: "not-an-object",
session: mockSession,
});
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow(
"Invalid user data in session"
);
});
it("should reject when user is null (typeof null === 'object' causes TypeError on 'in' operator)", async () => {
// Note: typeof null === "object" in JS, so the guard's typeof check passes
// but "id" in null throws TypeError. The catch block propagates non-auth errors as-is.
mockAuthService.verifySession.mockResolvedValue({
user: null,
session: mockSession,
});
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(TypeError);
await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(
UnauthorizedException
);
});
});
describe("request attachment", () => {
it("should attach user and session to request on success", async () => { it("should attach user and session to request on success", async () => {
mockAuthService.verifySession.mockResolvedValue(mockSessionData); mockAuthService.verifySession.mockResolvedValue(mockSessionData);

View File

@@ -1,22 +1,23 @@
import { import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from "@nestjs/common";
import { AuthService } from "../auth.service"; import { AuthService } from "../auth.service";
import type { AuthUser } from "@mosaic/shared"; import type { AuthUser } from "@mosaic/shared";
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
/**
* Request type with authentication context
*/
interface AuthRequest {
user?: AuthUser;
session?: Record<string, unknown>;
headers: Record<string, string | string[] | undefined>;
cookies?: Record<string, string>;
}
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<MaybeAuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthRequest>();
// Try to get token from either cookie (preferred) or Authorization header // Try to get token from either cookie (preferred) or Authorization header
const token = this.extractToken(request); const token = this.extractToken(request);
@@ -43,19 +44,18 @@ export class AuthGuard implements CanActivate {
return true; return true;
} catch (error) { } catch (error) {
// Re-throw if it's already an UnauthorizedException
if (error instanceof UnauthorizedException) { if (error instanceof UnauthorizedException) {
throw error; throw error;
} }
// Infrastructure errors (DB down, connection refused, timeouts) must propagate throw new UnauthorizedException("Authentication failed");
// as 500/503 via GlobalExceptionFilter — never mask as 401
throw error;
} }
} }
/** /**
* Extract token from cookie (preferred) or Authorization header * Extract token from cookie (preferred) or Authorization header
*/ */
private extractToken(request: MaybeAuthenticatedRequest): string | undefined { private extractToken(request: AuthRequest): string | undefined {
// Try cookie first (BetterAuth default) // Try cookie first (BetterAuth default)
const cookieToken = this.extractTokenFromCookie(request); const cookieToken = this.extractTokenFromCookie(request);
if (cookieToken) { if (cookieToken) {
@@ -67,39 +67,21 @@ export class AuthGuard implements CanActivate {
} }
/** /**
* Extract token from cookie. * Extract token from cookie (BetterAuth stores session token in better-auth.session_token cookie)
* BetterAuth may prefix the cookie name with "__Secure-" when running on HTTPS.
*/ */
private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined { private extractTokenFromCookie(request: AuthRequest): string | undefined {
// Express types `cookies` as `any`; cast to a known shape for type safety. if (!request.cookies) {
const cookies = request.cookies as Record<string, string> | undefined;
if (!cookies) {
return undefined; return undefined;
} }
// BetterAuth default cookie name is "better-auth.session_token" // BetterAuth uses 'better-auth.session_token' as the cookie name by default
// When Secure cookies are enabled, BetterAuth prefixes with "__Secure-". return request.cookies["better-auth.session_token"];
const candidates = [
"__Secure-better-auth.session_token",
"better-auth.session_token",
"__Host-better-auth.session_token",
] as const;
for (const name of candidates) {
const token = cookies[name];
if (token) {
this.logger.debug(`Session cookie found: ${name}`);
return token;
}
}
return undefined;
} }
/** /**
* Extract token from Authorization header (Bearer token) * Extract token from Authorization header (Bearer token)
*/ */
private extractTokenFromHeader(request: MaybeAuthenticatedRequest): string | undefined { private extractTokenFromHeader(request: AuthRequest): string | undefined {
const authHeader = request.headers.authorization; const authHeader = request.headers.authorization;
if (typeof authHeader !== "string") { if (typeof authHeader !== "string") {
return undefined; return undefined;

View File

@@ -1,10 +0,0 @@
import { IsEmail, IsString, MinLength } from "class-validator";
export class LocalLoginDto {
@IsEmail({}, { message: "email must be a valid email address" })
email!: string;
@IsString({ message: "password must be a string" })
@MinLength(1, { message: "password must not be empty" })
password!: string;
}

View File

@@ -1,20 +0,0 @@
import { IsEmail, IsString, MinLength, MaxLength } from "class-validator";
export class LocalSetupDto {
@IsEmail({}, { message: "email must be a valid email address" })
email!: string;
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string;
@IsString({ message: "password must be a string" })
@MinLength(12, { message: "password must be at least 12 characters" })
@MaxLength(128, { message: "password must not exceed 128 characters" })
password!: string;
@IsString({ message: "setupToken must be a string" })
@MinLength(1, { message: "setupToken must not be empty" })
setupToken!: string;
}

View File

@@ -1,232 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import {
NotFoundException,
ForbiddenException,
UnauthorizedException,
ConflictException,
} from "@nestjs/common";
import { LocalAuthController } from "./local-auth.controller";
import { LocalAuthService } from "./local-auth.service";
import { LocalAuthEnabledGuard } from "./local-auth.guard";
describe("LocalAuthController", () => {
let controller: LocalAuthController;
let localAuthService: LocalAuthService;
const mockLocalAuthService = {
setup: vi.fn(),
login: vi.fn(),
};
const mockRequest = {
headers: { "user-agent": "TestAgent/1.0" },
ip: "127.0.0.1",
socket: { remoteAddress: "127.0.0.1" },
};
const originalEnv = {
ENABLE_LOCAL_AUTH: process.env.ENABLE_LOCAL_AUTH,
};
beforeEach(async () => {
process.env.ENABLE_LOCAL_AUTH = "true";
const module: TestingModule = await Test.createTestingModule({
controllers: [LocalAuthController],
providers: [
{
provide: LocalAuthService,
useValue: mockLocalAuthService,
},
],
})
.overrideGuard(LocalAuthEnabledGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<LocalAuthController>(LocalAuthController);
localAuthService = module.get<LocalAuthService>(LocalAuthService);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
if (originalEnv.ENABLE_LOCAL_AUTH !== undefined) {
process.env.ENABLE_LOCAL_AUTH = originalEnv.ENABLE_LOCAL_AUTH;
} else {
delete process.env.ENABLE_LOCAL_AUTH;
}
});
describe("setup", () => {
const setupDto = {
email: "admin@example.com",
name: "Break Glass Admin",
password: "securePassword123!",
setupToken: "valid-token-123",
};
const mockSetupResult = {
user: {
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
createdAt: new Date("2026-02-28T00:00:00Z"),
},
session: {
token: "session-token-abc",
expiresAt: new Date("2026-03-07T00:00:00Z"),
},
};
it("should create a break-glass user and return user data with session", async () => {
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
const result = await controller.setup(setupDto, mockRequest as never);
expect(result).toEqual({
user: mockSetupResult.user,
session: mockSetupResult.session,
});
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
"admin@example.com",
"Break Glass Admin",
"securePassword123!",
"valid-token-123",
"127.0.0.1",
"TestAgent/1.0"
);
});
it("should extract client IP from x-forwarded-for header", async () => {
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
const reqWithProxy = {
...mockRequest,
headers: {
...mockRequest.headers,
"x-forwarded-for": "203.0.113.50, 70.41.3.18",
},
};
await controller.setup(setupDto, reqWithProxy as never);
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
expect.any(String) as string,
expect.any(String) as string,
expect.any(String) as string,
expect.any(String) as string,
"203.0.113.50",
"TestAgent/1.0"
);
});
it("should propagate ForbiddenException from service", async () => {
mockLocalAuthService.setup.mockRejectedValue(new ForbiddenException("Invalid setup token"));
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
ForbiddenException
);
});
it("should propagate ConflictException from service", async () => {
mockLocalAuthService.setup.mockRejectedValue(
new ConflictException("A user with this email already exists")
);
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
ConflictException
);
});
});
describe("login", () => {
const loginDto = {
email: "admin@example.com",
password: "securePassword123!",
};
const mockLoginResult = {
user: {
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
},
session: {
token: "session-token-abc",
expiresAt: new Date("2026-03-07T00:00:00Z"),
},
};
it("should authenticate and return user data with session", async () => {
mockLocalAuthService.login.mockResolvedValue(mockLoginResult);
const result = await controller.login(loginDto, mockRequest as never);
expect(result).toEqual({
user: mockLoginResult.user,
session: mockLoginResult.session,
});
expect(mockLocalAuthService.login).toHaveBeenCalledWith(
"admin@example.com",
"securePassword123!",
"127.0.0.1",
"TestAgent/1.0"
);
});
it("should propagate UnauthorizedException from service", async () => {
mockLocalAuthService.login.mockRejectedValue(
new UnauthorizedException("Invalid email or password")
);
await expect(controller.login(loginDto, mockRequest as never)).rejects.toThrow(
UnauthorizedException
);
});
});
});
describe("LocalAuthEnabledGuard", () => {
let guard: LocalAuthEnabledGuard;
const originalEnv = process.env.ENABLE_LOCAL_AUTH;
beforeEach(() => {
guard = new LocalAuthEnabledGuard();
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.ENABLE_LOCAL_AUTH = originalEnv;
} else {
delete process.env.ENABLE_LOCAL_AUTH;
}
});
it("should allow access when ENABLE_LOCAL_AUTH is true", () => {
process.env.ENABLE_LOCAL_AUTH = "true";
expect(guard.canActivate()).toBe(true);
});
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is not set", () => {
delete process.env.ENABLE_LOCAL_AUTH;
expect(() => guard.canActivate()).toThrow(NotFoundException);
});
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is false", () => {
process.env.ENABLE_LOCAL_AUTH = "false";
expect(() => guard.canActivate()).toThrow(NotFoundException);
});
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is empty", () => {
process.env.ENABLE_LOCAL_AUTH = "";
expect(() => guard.canActivate()).toThrow(NotFoundException);
});
});

View File

@@ -1,81 +0,0 @@
import {
Controller,
Post,
Body,
UseGuards,
Req,
Logger,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import type { Request as ExpressRequest } from "express";
import { SkipCsrf } from "../../common/decorators/skip-csrf.decorator";
import { LocalAuthService } from "./local-auth.service";
import { LocalAuthEnabledGuard } from "./local-auth.guard";
import { LocalLoginDto } from "./dto/local-login.dto";
import { LocalSetupDto } from "./dto/local-setup.dto";
@Controller("auth/local")
@UseGuards(LocalAuthEnabledGuard)
export class LocalAuthController {
private readonly logger = new Logger(LocalAuthController.name);
constructor(private readonly localAuthService: LocalAuthService) {}
/**
* First-time break-glass user creation.
* Requires BREAKGLASS_SETUP_TOKEN from environment.
*/
@Post("setup")
@SkipCsrf()
@Throttle({ strict: { limit: 5, ttl: 60000 } })
async setup(@Body() dto: LocalSetupDto, @Req() req: ExpressRequest) {
const ipAddress = this.getClientIp(req);
const userAgent = req.headers["user-agent"];
this.logger.log(`Break-glass setup attempt from ${ipAddress}`);
const result = await this.localAuthService.setup(
dto.email,
dto.name,
dto.password,
dto.setupToken,
ipAddress,
userAgent
);
return {
user: result.user,
session: result.session,
};
}
/**
* Break-glass login with email + password.
*/
@Post("login")
@SkipCsrf()
@HttpCode(HttpStatus.OK)
@Throttle({ strict: { limit: 10, ttl: 60000 } })
async login(@Body() dto: LocalLoginDto, @Req() req: ExpressRequest) {
const ipAddress = this.getClientIp(req);
const userAgent = req.headers["user-agent"];
const result = await this.localAuthService.login(dto.email, dto.password, ipAddress, userAgent);
return {
user: result.user,
session: result.session,
};
}
private getClientIp(req: ExpressRequest): string {
const forwardedFor = req.headers["x-forwarded-for"];
if (forwardedFor) {
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
return ips?.split(",")[0]?.trim() ?? "unknown";
}
return req.ip ?? req.socket.remoteAddress ?? "unknown";
}
}

View File

@@ -1,15 +0,0 @@
import { Injectable, CanActivate, NotFoundException } from "@nestjs/common";
/**
* Guard that checks if local authentication is enabled via ENABLE_LOCAL_AUTH env var.
* Returns 404 when disabled so endpoints are invisible to callers.
*/
@Injectable()
export class LocalAuthEnabledGuard implements CanActivate {
canActivate(): boolean {
if (process.env.ENABLE_LOCAL_AUTH !== "true") {
throw new NotFoundException();
}
return true;
}
}

View File

@@ -1,389 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import {
ConflictException,
ForbiddenException,
InternalServerErrorException,
UnauthorizedException,
} from "@nestjs/common";
import { hash } from "bcryptjs";
import { LocalAuthService } from "./local-auth.service";
import { PrismaService } from "../../prisma/prisma.service";
describe("LocalAuthService", () => {
let service: LocalAuthService;
const mockTxSession = {
create: vi.fn(),
};
const mockTxWorkspace = {
findFirst: vi.fn(),
create: vi.fn(),
};
const mockTxWorkspaceMember = {
create: vi.fn(),
};
const mockTxUser = {
create: vi.fn(),
findUnique: vi.fn(),
};
const mockTx = {
user: mockTxUser,
workspace: mockTxWorkspace,
workspaceMember: mockTxWorkspaceMember,
session: mockTxSession,
};
const mockPrismaService = {
user: {
findUnique: vi.fn(),
},
session: {
create: vi.fn(),
},
$transaction: vi
.fn()
.mockImplementation((fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
};
const originalEnv = {
BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LocalAuthService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<LocalAuthService>(LocalAuthService);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) {
process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN;
} else {
delete process.env.BREAKGLASS_SETUP_TOKEN;
}
});
describe("setup", () => {
const validSetupArgs = {
email: "admin@example.com",
name: "Break Glass Admin",
password: "securePassword123!",
setupToken: "valid-token-123",
};
const mockCreatedUser = {
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
createdAt: new Date("2026-02-28T00:00:00Z"),
};
const mockWorkspace = {
id: "workspace-uuid-123",
};
beforeEach(() => {
process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123";
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockTxUser.create.mockResolvedValue(mockCreatedUser);
mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace);
mockTxWorkspaceMember.create.mockResolvedValue({});
mockTxSession.create.mockResolvedValue({});
});
it("should create a local auth user with hashed password", async () => {
const result = await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(result.user).toEqual(mockCreatedUser);
expect(result.session.token).toBeDefined();
expect(result.session.token.length).toBeGreaterThan(0);
expect(result.session.expiresAt).toBeInstanceOf(Date);
expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(mockTxUser.create).toHaveBeenCalledWith({
data: expect.objectContaining({
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
emailVerified: true,
passwordHash: expect.any(String) as string,
}),
select: {
id: true,
email: true,
name: true,
isLocalAuth: true,
createdAt: true,
},
});
});
it("should assign OWNER role on default workspace", async () => {
await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: "workspace-uuid-123",
userId: "user-uuid-123",
role: "OWNER",
},
});
});
it("should create a new workspace if none exists", async () => {
mockTxWorkspace.findFirst.mockResolvedValue(null);
mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" });
await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(mockTxWorkspace.create).toHaveBeenCalledWith({
data: {
name: "Default Workspace",
ownerId: "user-uuid-123",
settings: {},
},
select: { id: true },
});
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: "new-workspace-uuid",
userId: "user-uuid-123",
role: "OWNER",
},
});
});
it("should create a BetterAuth-compatible session", async () => {
await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken,
"192.168.1.1",
"TestAgent/1.0"
);
expect(mockTxSession.create).toHaveBeenCalledWith({
data: {
userId: "user-uuid-123",
token: expect.any(String) as string,
expiresAt: expect.any(Date) as Date,
ipAddress: "192.168.1.1",
userAgent: "TestAgent/1.0",
},
});
});
it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => {
delete process.env.BREAKGLASS_SETUP_TOKEN;
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
)
).rejects.toThrow(ForbiddenException);
});
it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => {
process.env.BREAKGLASS_SETUP_TOKEN = "";
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
)
).rejects.toThrow(ForbiddenException);
});
it("should reject when setup token does not match", async () => {
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
"wrong-token"
)
).rejects.toThrow(ForbiddenException);
});
it("should reject when email already exists", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: "existing-user",
email: "admin@example.com",
});
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
)
).rejects.toThrow(ConflictException);
});
it("should return session token and expiry", async () => {
const result = await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(typeof result.session.token).toBe("string");
expect(result.session.token.length).toBe(64); // 32 bytes hex
expect(result.session.expiresAt).toBeInstanceOf(Date);
});
});
describe("login", () => {
const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl";
beforeEach(async () => {
// Create a real bcrypt hash for testing
const realHash = await hash("securePassword123!", 4); // Low rounds for test speed
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
passwordHash: realHash,
deactivatedAt: null,
});
mockPrismaService.session.create.mockResolvedValue({});
});
it("should authenticate a valid local auth user", async () => {
const result = await service.login("admin@example.com", "securePassword123!");
expect(result.user).toEqual({
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
});
expect(result.session.token).toBeDefined();
expect(result.session.expiresAt).toBeInstanceOf(Date);
});
it("should create a session with ip and user agent", async () => {
await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0");
expect(mockPrismaService.session.create).toHaveBeenCalledWith({
data: {
userId: "user-uuid-123",
token: expect.any(String) as string,
expiresAt: expect.any(Date) as Date,
ipAddress: "10.0.0.1",
userAgent: "Mozilla/5.0",
},
});
});
it("should reject when user does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow(
UnauthorizedException
);
});
it("should reject when user is not a local auth user", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "OIDC User",
isLocalAuth: false,
passwordHash: null,
deactivatedAt: null,
});
await expect(service.login("admin@example.com", "password123456")).rejects.toThrow(
UnauthorizedException
);
});
it("should reject when user is deactivated", async () => {
const realHash = await hash("securePassword123!", 4);
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "Deactivated User",
isLocalAuth: true,
passwordHash: realHash,
deactivatedAt: new Date("2026-01-01"),
});
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
new UnauthorizedException("Account has been deactivated")
);
});
it("should reject when password is incorrect", async () => {
await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow(
UnauthorizedException
);
});
it("should throw InternalServerError when local auth user has no password hash", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "Broken User",
isLocalAuth: true,
passwordHash: null,
deactivatedAt: null,
});
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
InternalServerErrorException
);
});
it("should not reveal whether email exists in error messages", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
try {
await service.login("nonexistent@example.com", "password123456");
} catch (error) {
expect(error).toBeInstanceOf(UnauthorizedException);
expect((error as UnauthorizedException).message).toBe("Invalid email or password");
}
});
});
});

View File

@@ -1,230 +0,0 @@
import {
Injectable,
Logger,
ForbiddenException,
UnauthorizedException,
ConflictException,
InternalServerErrorException,
} from "@nestjs/common";
import { WorkspaceMemberRole } from "@prisma/client";
import { hash, compare } from "bcryptjs";
import { randomBytes, timingSafeEqual } from "crypto";
import { PrismaService } from "../../prisma/prisma.service";
const BCRYPT_ROUNDS = 12;
/** Session expiry: 7 days (matches BetterAuth config in auth.config.ts) */
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
interface SetupResult {
user: {
id: string;
email: string;
name: string;
isLocalAuth: boolean;
createdAt: Date;
};
session: {
token: string;
expiresAt: Date;
};
}
interface LoginResult {
user: {
id: string;
email: string;
name: string;
};
session: {
token: string;
expiresAt: Date;
};
}
@Injectable()
export class LocalAuthService {
private readonly logger = new Logger(LocalAuthService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* First-time break-glass user creation.
* Validates the setup token, creates a local auth user with bcrypt-hashed password,
* and assigns OWNER role on the default workspace.
*/
async setup(
email: string,
name: string,
password: string,
setupToken: string,
ipAddress?: string,
userAgent?: string
): Promise<SetupResult> {
this.validateSetupToken(setupToken);
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) {
throw new ConflictException("A user with this email already exists");
}
const passwordHash = await hash(password, BCRYPT_ROUNDS);
const result = await this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email,
name,
isLocalAuth: true,
passwordHash,
emailVerified: true,
},
select: {
id: true,
email: true,
name: true,
isLocalAuth: true,
createdAt: true,
},
});
// Find or create a default workspace and assign OWNER role
await this.assignDefaultWorkspace(tx, user.id);
// Create a BetterAuth-compatible session
const session = await this.createSession(tx, user.id, ipAddress, userAgent);
return { user, session };
});
this.logger.log(`Break-glass user created: ${email}`);
return result;
}
/**
* Break-glass login: verify email + password against bcrypt hash.
* Only works for users with isLocalAuth=true.
*/
async login(
email: string,
password: string,
ipAddress?: string,
userAgent?: string
): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
isLocalAuth: true,
passwordHash: true,
deactivatedAt: true,
},
});
if (!user?.isLocalAuth) {
throw new UnauthorizedException("Invalid email or password");
}
if (user.deactivatedAt) {
throw new UnauthorizedException("Account has been deactivated");
}
if (!user.passwordHash) {
this.logger.error(`Local auth user ${email} has no password hash`);
throw new InternalServerErrorException("Account configuration error");
}
const passwordValid = await compare(password, user.passwordHash);
if (!passwordValid) {
throw new UnauthorizedException("Invalid email or password");
}
const session = await this.createSession(this.prisma, user.id, ipAddress, userAgent);
this.logger.log(`Break-glass login: ${email}`);
return {
user: { id: user.id, email: user.email, name: user.name },
session,
};
}
/**
* Validate the setup token against the environment variable.
*/
private validateSetupToken(token: string): void {
const expectedToken = process.env.BREAKGLASS_SETUP_TOKEN;
if (!expectedToken || expectedToken.trim() === "") {
throw new ForbiddenException(
"Break-glass setup is not configured. Set BREAKGLASS_SETUP_TOKEN environment variable."
);
}
const tokenBuffer = Buffer.from(token);
const expectedBuffer = Buffer.from(expectedToken);
if (
tokenBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(tokenBuffer, expectedBuffer)
) {
this.logger.warn("Invalid break-glass setup token attempt");
throw new ForbiddenException("Invalid setup token");
}
}
/**
* Find the first workspace or create a default one, then assign OWNER role.
*/
private async assignDefaultWorkspace(
tx: Parameters<Parameters<PrismaService["$transaction"]>[0]>[0],
userId: string
): Promise<void> {
let workspace = await tx.workspace.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
workspace ??= await tx.workspace.create({
data: {
name: "Default Workspace",
ownerId: userId,
settings: {},
},
select: { id: true },
});
await tx.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId,
role: WorkspaceMemberRole.OWNER,
},
});
}
/**
* Create a BetterAuth-compatible session record.
*/
private async createSession(
tx: { session: { create: typeof PrismaService.prototype.session.create } },
userId: string,
ipAddress?: string,
userAgent?: string
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
await tx.session.create({
data: {
userId,
token,
expiresAt,
ipAddress: ipAddress ?? null,
userAgent: userAgent ?? null,
},
});
return { token, expiresAt };
}
}

View File

@@ -1,14 +1,11 @@
/** /**
* Unified request types for authentication context. * BetterAuth Request Type
* *
* Replaces the previously scattered interfaces: * BetterAuth expects a Request object compatible with the Fetch API standard.
* - RequestWithSession (auth.controller.ts) * This extends the web standard Request interface with additional properties
* - AuthRequest (auth.guard.ts) * that may be present in the Express request object at runtime.
* - BetterAuthRequest (this file, removed)
* - RequestWithUser (current-user.decorator.ts)
*/ */
import type { Request } from "express";
import type { AuthUser } from "@mosaic/shared"; import type { AuthUser } from "@mosaic/shared";
// Re-export AuthUser for use in other modules // Re-export AuthUser for use in other modules
@@ -25,21 +22,19 @@ export interface RequestSession {
} }
/** /**
* Request that may or may not have auth data (before guard runs). * Web standard Request interface extended with Express-specific properties
* Used by AuthGuard and other middleware that processes requests * This matches the Fetch API Request specification that BetterAuth expects.
* before authentication is confirmed.
*/ */
export interface MaybeAuthenticatedRequest extends Request { export interface BetterAuthRequest extends Request {
user?: AuthUser; // Express route parameters
session?: Record<string, unknown>; params?: Record<string, string>;
}
/** // Express query string parameters
* Request with authenticated user attached by AuthGuard. query?: Record<string, string | string[]>;
* After AuthGuard runs, user and session are guaranteed present.
* Use this type in controllers/decorators that sit behind AuthGuard. // Session data attached by AuthGuard after successful authentication
*/ session?: RequestSession;
export interface AuthenticatedRequest extends Request {
user: AuthUser; // Authenticated user attached by AuthGuard
session: RequestSession; user?: AuthUser;
} }

View File

@@ -1,15 +0,0 @@
/**
* Bridge Module Constants
*
* Injection tokens for the bridge module.
*/
/**
* Injection token for the array of active IChatProvider instances.
*
* Use this token to inject all configured chat providers:
* ```
* @Inject(CHAT_PROVIDERS) private readonly chatProviders: IChatProvider[]
* ```
*/
export const CHAT_PROVIDERS = "CHAT_PROVIDERS";

View File

@@ -1,13 +1,10 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { BridgeModule } from "./bridge.module"; import { BridgeModule } from "./bridge.module";
import { DiscordService } from "./discord/discord.service"; import { DiscordService } from "./discord/discord.service";
import { MatrixService } from "./matrix/matrix.service";
import { StitcherService } from "../stitcher/stitcher.service"; import { StitcherService } from "../stitcher/stitcher.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service"; import { BullMqService } from "../bullmq/bullmq.service";
import { CHAT_PROVIDERS } from "./bridge.constants"; import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IChatProvider } from "./interfaces";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Mock discord.js // Mock discord.js
const mockReadyCallbacks: Array<() => void> = []; const mockReadyCallbacks: Array<() => void> = [];
@@ -56,93 +53,20 @@ vi.mock("discord.js", () => {
}; };
}); });
// Mock matrix-bot-sdk
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn();
on = vi.fn();
sendMessage = vi.fn().mockResolvedValue("$mock-event-id");
},
SimpleFsStorageProvider: class MockStorage {
constructor(_path: string) {
// no-op
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
/**
* Saved environment variables to restore after each test
*/
interface SavedEnvVars {
DISCORD_BOT_TOKEN?: string;
DISCORD_GUILD_ID?: string;
DISCORD_CONTROL_CHANNEL_ID?: string;
MATRIX_ACCESS_TOKEN?: string;
MATRIX_HOMESERVER_URL?: string;
MATRIX_BOT_USER_ID?: string;
MATRIX_CONTROL_ROOM_ID?: string;
MATRIX_WORKSPACE_ID?: string;
ENCRYPTION_KEY?: string;
}
describe("BridgeModule", () => { describe("BridgeModule", () => {
let savedEnv: SavedEnvVars; let module: TestingModule;
beforeEach(() => { beforeEach(async () => {
// Save current env vars // Set environment variables
savedEnv = { process.env.DISCORD_BOT_TOKEN = "test-token";
DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, process.env.DISCORD_GUILD_ID = "test-guild-id";
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID, process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
DISCORD_CONTROL_CHANNEL_ID: process.env.DISCORD_CONTROL_CHANNEL_ID,
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
MATRIX_HOMESERVER_URL: process.env.MATRIX_HOMESERVER_URL,
MATRIX_BOT_USER_ID: process.env.MATRIX_BOT_USER_ID,
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
};
// Clear all bridge env vars
delete process.env.DISCORD_BOT_TOKEN;
delete process.env.DISCORD_GUILD_ID;
delete process.env.DISCORD_CONTROL_CHANNEL_ID;
delete process.env.MATRIX_ACCESS_TOKEN;
delete process.env.MATRIX_HOMESERVER_URL;
delete process.env.MATRIX_BOT_USER_ID;
delete process.env.MATRIX_CONTROL_ROOM_ID;
delete process.env.MATRIX_WORKSPACE_ID;
// Set encryption key (needed by StitcherService)
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// Clear ready callbacks // Clear ready callbacks
mockReadyCallbacks.length = 0; mockReadyCallbacks.length = 0;
vi.clearAllMocks(); module = await Test.createTestingModule({
});
afterEach(() => {
// Restore env vars
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
/**
* Helper to compile a test module with BridgeModule
*/
async function compileModule(): Promise<TestingModule> {
return Test.createTestingModule({
imports: [BridgeModule], imports: [BridgeModule],
}) })
.overrideProvider(PrismaService) .overrideProvider(PrismaService)
@@ -150,144 +74,24 @@ describe("BridgeModule", () => {
.overrideProvider(BullMqService) .overrideProvider(BullMqService)
.useValue({}) .useValue({})
.compile(); .compile();
}
/** // Clear all mocks
* Helper to set Discord env vars vi.clearAllMocks();
*/
function setDiscordEnv(): void {
process.env.DISCORD_BOT_TOKEN = "test-discord-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
}
/**
* Helper to set Matrix env vars
*/
function setMatrixEnv(): void {
process.env.MATRIX_ACCESS_TOKEN = "test-matrix-token";
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
process.env.MATRIX_BOT_USER_ID = "@bot:example.com";
process.env.MATRIX_CONTROL_ROOM_ID = "!room:example.com";
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
}
describe("with both Discord and Matrix configured", () => {
let module: TestingModule;
beforeEach(async () => {
setDiscordEnv();
setMatrixEnv();
module = await compileModule();
});
it("should compile the module", () => {
expect(module).toBeDefined();
});
it("should provide DiscordService", () => {
const discordService = module.get<DiscordService>(DiscordService);
expect(discordService).toBeDefined();
expect(discordService).toBeInstanceOf(DiscordService);
});
it("should provide MatrixService", () => {
const matrixService = module.get<MatrixService>(MatrixService);
expect(matrixService).toBeDefined();
expect(matrixService).toBeInstanceOf(MatrixService);
});
it("should provide CHAT_PROVIDERS with both providers", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(2);
expect(chatProviders[0]).toBeInstanceOf(DiscordService);
expect(chatProviders[1]).toBeInstanceOf(MatrixService);
});
it("should provide StitcherService via StitcherModule", () => {
const stitcherService = module.get<StitcherService>(StitcherService);
expect(stitcherService).toBeDefined();
expect(stitcherService).toBeInstanceOf(StitcherService);
});
}); });
describe("with only Discord configured", () => { it("should be defined", () => {
let module: TestingModule; expect(module).toBeDefined();
beforeEach(async () => {
setDiscordEnv();
module = await compileModule();
});
it("should compile the module", () => {
expect(module).toBeDefined();
});
it("should provide DiscordService", () => {
const discordService = module.get<DiscordService>(DiscordService);
expect(discordService).toBeDefined();
expect(discordService).toBeInstanceOf(DiscordService);
});
it("should provide CHAT_PROVIDERS with only Discord", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(1);
expect(chatProviders[0]).toBeInstanceOf(DiscordService);
});
}); });
describe("with only Matrix configured", () => { it("should provide DiscordService", () => {
let module: TestingModule; const discordService = module.get<DiscordService>(DiscordService);
expect(discordService).toBeDefined();
beforeEach(async () => { expect(discordService).toBeInstanceOf(DiscordService);
setMatrixEnv();
module = await compileModule();
});
it("should compile the module", () => {
expect(module).toBeDefined();
});
it("should provide MatrixService", () => {
const matrixService = module.get<MatrixService>(MatrixService);
expect(matrixService).toBeDefined();
expect(matrixService).toBeInstanceOf(MatrixService);
});
it("should provide CHAT_PROVIDERS with only Matrix", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(1);
expect(chatProviders[0]).toBeInstanceOf(MatrixService);
});
}); });
describe("with neither bridge configured", () => { it("should provide StitcherService", () => {
let module: TestingModule; const stitcherService = module.get<StitcherService>(StitcherService);
expect(stitcherService).toBeDefined();
beforeEach(async () => { expect(stitcherService).toBeInstanceOf(StitcherService);
// No env vars set for either bridge
module = await compileModule();
});
it("should compile the module without errors", () => {
expect(module).toBeDefined();
});
it("should provide CHAT_PROVIDERS as an empty array", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(0);
expect(Array.isArray(chatProviders)).toBe(true);
});
});
describe("CHAT_PROVIDERS token", () => {
it("should be a string constant", () => {
expect(CHAT_PROVIDERS).toBe("CHAT_PROVIDERS");
expect(typeof CHAT_PROVIDERS).toBe("string");
});
}); });
}); });

View File

@@ -1,81 +1,16 @@
import { Logger, Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { DiscordService } from "./discord/discord.service"; import { DiscordService } from "./discord/discord.service";
import { MatrixService } from "./matrix/matrix.service";
import { MatrixRoomService } from "./matrix/matrix-room.service";
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
import { CommandParserService } from "./parser/command-parser.service";
import { StitcherModule } from "../stitcher/stitcher.module"; import { StitcherModule } from "../stitcher/stitcher.module";
import { CHAT_PROVIDERS } from "./bridge.constants";
import type { IChatProvider } from "./interfaces";
const logger = new Logger("BridgeModule");
/** /**
* Bridge Module - Chat platform integrations * Bridge Module - Chat platform integrations
* *
* Provides integration with chat platforms (Discord, Matrix, etc.) * Provides integration with chat platforms (Discord, Slack, Matrix, etc.)
* for controlling Mosaic Stack via chat commands. * for controlling Mosaic Stack via chat commands.
*
* Both services are always registered as providers, but the CHAT_PROVIDERS
* injection token only includes bridges whose environment variables are set:
* - Discord: included when DISCORD_BOT_TOKEN is set
* - Matrix: included when MATRIX_ACCESS_TOKEN is set
*
* Both bridges can run simultaneously, and no error occurs if neither is configured.
* Consumers should inject CHAT_PROVIDERS for bridge-agnostic access to all active providers.
*
* CommandParserService provides shared, platform-agnostic command parsing.
* MatrixRoomService handles workspace-to-Matrix-room mapping.
*/ */
@Module({ @Module({
imports: [StitcherModule], imports: [StitcherModule],
providers: [ providers: [DiscordService],
CommandParserService, exports: [DiscordService],
MatrixRoomService,
MatrixStreamingService,
DiscordService,
MatrixService,
{
provide: CHAT_PROVIDERS,
useFactory: (discord: DiscordService, matrix: MatrixService): IChatProvider[] => {
const providers: IChatProvider[] = [];
if (process.env.DISCORD_BOT_TOKEN) {
providers.push(discord);
logger.log("Discord bridge enabled (DISCORD_BOT_TOKEN detected)");
}
if (process.env.MATRIX_ACCESS_TOKEN) {
const missingVars = [
"MATRIX_HOMESERVER_URL",
"MATRIX_BOT_USER_ID",
"MATRIX_WORKSPACE_ID",
].filter((v) => !process.env[v]);
if (missingVars.length > 0) {
logger.warn(
`Matrix bridge enabled but missing: ${missingVars.join(", ")}. connect() will fail.`
);
}
providers.push(matrix);
logger.log("Matrix bridge enabled (MATRIX_ACCESS_TOKEN detected)");
}
if (providers.length === 0) {
logger.warn("No chat bridges configured. Set DISCORD_BOT_TOKEN or MATRIX_ACCESS_TOKEN.");
}
return providers;
},
inject: [DiscordService, MatrixService],
},
],
exports: [
DiscordService,
MatrixService,
MatrixRoomService,
MatrixStreamingService,
CommandParserService,
CHAT_PROVIDERS,
],
}) })
export class BridgeModule {} export class BridgeModule {}

View File

@@ -187,7 +187,6 @@ describe("DiscordService", () => {
await service.connect(); await service.connect();
await service.sendThreadMessage({ await service.sendThreadMessage({
threadId: "thread-123", threadId: "thread-123",
channelId: "test-channel-id",
content: "Step completed", content: "Step completed",
}); });

View File

@@ -305,7 +305,6 @@ export class DiscordService implements IChatProvider {
// Send confirmation to thread // Send confirmation to thread
await this.sendThreadMessage({ await this.sendThreadMessage({
threadId, threadId,
channelId: message.channelId,
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`, content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
}); });
} }

View File

@@ -28,7 +28,6 @@ export interface ThreadCreateOptions {
export interface ThreadMessageOptions { export interface ThreadMessageOptions {
threadId: string; threadId: string;
channelId: string;
content: string; content: string;
} }
@@ -77,17 +76,4 @@ export interface IChatProvider {
* Parse a command from a message * Parse a command from a message
*/ */
parseCommand(message: ChatMessage): ChatCommand | null; parseCommand(message: ChatMessage): ChatCommand | null;
/**
* Edit an existing message in a channel.
*
* Optional method for providers that support message editing
* (e.g., Matrix via m.replace, Discord via message.edit).
* Used for streaming AI responses with incremental updates.
*
* @param channelId - The channel/room ID
* @param messageId - The original message/event ID to edit
* @param content - The updated message content
*/
editMessage?(channelId: string, messageId: string, content: string): Promise<void>;
} }

View File

@@ -1,4 +0,0 @@
export { MatrixService } from "./matrix.service";
export { MatrixRoomService } from "./matrix-room.service";
export { MatrixStreamingService } from "./matrix-streaming.service";
export type { StreamResponseOptions } from "./matrix-streaming.service";

File diff suppressed because it is too large Load Diff

View File

@@ -1,212 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { MatrixRoomService } from "./matrix-room.service";
import { MatrixService } from "./matrix.service";
import { PrismaService } from "../../prisma/prisma.service";
import { vi, describe, it, expect, beforeEach } from "vitest";
// Mock matrix-bot-sdk to avoid native module import errors
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {},
SimpleFsStorageProvider: class MockStorageProvider {
constructor(_filename: string) {
// No-op for testing
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
describe("MatrixRoomService", () => {
let service: MatrixRoomService;
const mockCreateRoom = vi.fn().mockResolvedValue("!new-room:example.com");
const mockMatrixClient = {
createRoom: mockCreateRoom,
};
const mockMatrixService = {
isConnected: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockReturnValue(mockMatrixClient),
};
const mockPrismaService = {
workspace: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
};
beforeEach(async () => {
process.env.MATRIX_SERVER_NAME = "example.com";
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixRoomService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
{
provide: MatrixService,
useValue: mockMatrixService,
},
],
}).compile();
service = module.get<MatrixRoomService>(MatrixRoomService);
vi.clearAllMocks();
// Restore defaults after clearing
mockMatrixService.isConnected.mockReturnValue(true);
mockCreateRoom.mockResolvedValue("!new-room:example.com");
mockPrismaService.workspace.update.mockResolvedValue({});
});
describe("provisionRoom", () => {
it("should create a Matrix room and store the mapping", async () => {
const roomId = await service.provisionRoom(
"workspace-uuid-1",
"My Workspace",
"my-workspace"
);
expect(roomId).toBe("!new-room:example.com");
expect(mockCreateRoom).toHaveBeenCalledWith({
name: "Mosaic: My Workspace",
room_alias_name: "mosaic-my-workspace",
topic: "Mosaic workspace: My Workspace",
preset: "private_chat",
visibility: "private",
});
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
data: { matrixRoomId: "!new-room:example.com" },
});
});
it("should return null when Matrix is not configured (no MatrixService)", async () => {
// Create a service without MatrixService
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixRoomService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
const serviceWithoutMatrix = module.get<MatrixRoomService>(MatrixRoomService);
const roomId = await serviceWithoutMatrix.provisionRoom(
"workspace-uuid-1",
"My Workspace",
"my-workspace"
);
expect(roomId).toBeNull();
expect(mockCreateRoom).not.toHaveBeenCalled();
expect(mockPrismaService.workspace.update).not.toHaveBeenCalled();
});
it("should return null when Matrix is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
const roomId = await service.provisionRoom(
"workspace-uuid-1",
"My Workspace",
"my-workspace"
);
expect(roomId).toBeNull();
expect(mockCreateRoom).not.toHaveBeenCalled();
});
});
describe("getRoomForWorkspace", () => {
it("should return the room ID for a mapped workspace", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue({
matrixRoomId: "!mapped-room:example.com",
});
const roomId = await service.getRoomForWorkspace("workspace-uuid-1");
expect(roomId).toBe("!mapped-room:example.com");
expect(mockPrismaService.workspace.findUnique).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
select: { matrixRoomId: true },
});
});
it("should return null for an unmapped workspace", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue({
matrixRoomId: null,
});
const roomId = await service.getRoomForWorkspace("workspace-uuid-2");
expect(roomId).toBeNull();
});
it("should return null for a non-existent workspace", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
const roomId = await service.getRoomForWorkspace("non-existent-uuid");
expect(roomId).toBeNull();
});
});
describe("getWorkspaceForRoom", () => {
it("should return the workspace ID for a mapped room", async () => {
mockPrismaService.workspace.findFirst.mockResolvedValue({
id: "workspace-uuid-1",
});
const workspaceId = await service.getWorkspaceForRoom("!mapped-room:example.com");
expect(workspaceId).toBe("workspace-uuid-1");
expect(mockPrismaService.workspace.findFirst).toHaveBeenCalledWith({
where: { matrixRoomId: "!mapped-room:example.com" },
select: { id: true },
});
});
it("should return null for an unmapped room", async () => {
mockPrismaService.workspace.findFirst.mockResolvedValue(null);
const workspaceId = await service.getWorkspaceForRoom("!unknown-room:example.com");
expect(workspaceId).toBeNull();
});
});
describe("linkWorkspaceToRoom", () => {
it("should store the room mapping in the workspace", async () => {
await service.linkWorkspaceToRoom("workspace-uuid-1", "!existing-room:example.com");
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
data: { matrixRoomId: "!existing-room:example.com" },
});
});
});
describe("unlinkWorkspace", () => {
it("should remove the room mapping from the workspace", async () => {
await service.unlinkWorkspace("workspace-uuid-1");
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
data: { matrixRoomId: null },
});
});
});
});

View File

@@ -1,154 +0,0 @@
import { Injectable, Logger, Optional, Inject } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import { MatrixService } from "./matrix.service";
import type { MatrixClient, RoomCreateOptions } from "matrix-bot-sdk";
/**
* MatrixRoomService - Workspace-to-Matrix-Room mapping and provisioning
*
* Responsibilities:
* - Provision Matrix rooms for Mosaic workspaces
* - Map workspaces to Matrix room IDs
* - Link/unlink existing rooms to workspaces
*
* Room provisioning creates a private Matrix room with:
* - Name: "Mosaic: {workspace_name}"
* - Alias: #mosaic-{workspace_slug}:{server_name}
* - Room ID stored in workspace.matrixRoomId
*/
@Injectable()
export class MatrixRoomService {
private readonly logger = new Logger(MatrixRoomService.name);
constructor(
private readonly prisma: PrismaService,
@Optional() @Inject(MatrixService) private readonly matrixService: MatrixService | null
) {}
/**
* Provision a Matrix room for a workspace and store the mapping.
*
* @param workspaceId - The workspace UUID
* @param workspaceName - Human-readable workspace name
* @param workspaceSlug - URL-safe workspace identifier for the room alias
* @returns The Matrix room ID, or null if Matrix is not configured
*/
async provisionRoom(
workspaceId: string,
workspaceName: string,
workspaceSlug: string
): Promise<string | null> {
if (!this.matrixService?.isConnected()) {
this.logger.warn("Matrix is not configured or not connected; skipping room provisioning");
return null;
}
const client = this.getMatrixClient();
if (!client) {
this.logger.warn("Matrix client is not available; skipping room provisioning");
return null;
}
const roomOptions: RoomCreateOptions = {
name: `Mosaic: ${workspaceName}`,
room_alias_name: `mosaic-${workspaceSlug}`,
topic: `Mosaic workspace: ${workspaceName}`,
preset: "private_chat",
visibility: "private",
};
this.logger.log(
`Provisioning Matrix room for workspace "${workspaceName}" (${workspaceId})...`
);
const roomId = await client.createRoom(roomOptions);
// Store the room mapping
try {
await this.prisma.workspace.update({
where: { id: workspaceId },
data: { matrixRoomId: roomId },
});
} catch (dbError: unknown) {
this.logger.error(
`Failed to store room mapping for workspace ${workspaceId}, room ${roomId} may be orphaned: ${dbError instanceof Error ? dbError.message : "unknown"}`
);
throw dbError;
}
this.logger.log(`Matrix room ${roomId} provisioned and linked to workspace ${workspaceId}`);
return roomId;
}
/**
* Look up the Matrix room ID mapped to a workspace.
*
* @param workspaceId - The workspace UUID
* @returns The Matrix room ID, or null if no room is mapped
*/
async getRoomForWorkspace(workspaceId: string): Promise<string | null> {
const workspace = await this.prisma.workspace.findUnique({
where: { id: workspaceId },
select: { matrixRoomId: true },
});
if (!workspace) {
return null;
}
return workspace.matrixRoomId ?? null;
}
/**
* Reverse lookup: find the workspace that owns a given Matrix room.
*
* @param roomId - The Matrix room ID (e.g. "!abc:example.com")
* @returns The workspace ID, or null if the room is not mapped to any workspace
*/
async getWorkspaceForRoom(roomId: string): Promise<string | null> {
const workspace = await this.prisma.workspace.findFirst({
where: { matrixRoomId: roomId },
select: { id: true },
});
return workspace?.id ?? null;
}
/**
* Manually link an existing Matrix room to a workspace.
*
* @param workspaceId - The workspace UUID
* @param roomId - The Matrix room ID to link
*/
async linkWorkspaceToRoom(workspaceId: string, roomId: string): Promise<void> {
await this.prisma.workspace.update({
where: { id: workspaceId },
data: { matrixRoomId: roomId },
});
this.logger.log(`Linked workspace ${workspaceId} to Matrix room ${roomId}`);
}
/**
* Remove the Matrix room mapping from a workspace.
*
* @param workspaceId - The workspace UUID
*/
async unlinkWorkspace(workspaceId: string): Promise<void> {
await this.prisma.workspace.update({
where: { id: workspaceId },
data: { matrixRoomId: null },
});
this.logger.log(`Unlinked Matrix room from workspace ${workspaceId}`);
}
/**
* Access the underlying MatrixClient from the MatrixService
* via the public getClient() accessor.
*/
private getMatrixClient(): MatrixClient | null {
if (!this.matrixService) return null;
return this.matrixService.getClient();
}
}

View File

@@ -1,408 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { MatrixStreamingService } from "./matrix-streaming.service";
import { MatrixService } from "./matrix.service";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import type { StreamResponseOptions } from "./matrix-streaming.service";
// Mock matrix-bot-sdk to prevent native module loading
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {},
SimpleFsStorageProvider: class MockStorageProvider {
constructor(_filename: string) {
// No-op for testing
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
// Mock MatrixClient
const mockClient = {
sendMessage: vi.fn().mockResolvedValue("$initial-event-id"),
sendEvent: vi.fn().mockResolvedValue("$edit-event-id"),
setTyping: vi.fn().mockResolvedValue(undefined),
};
// Mock MatrixService
const mockMatrixService = {
isConnected: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockReturnValue(mockClient),
};
/**
* Helper: create an async iterable from an array of strings with optional delays
*/
async function* createTokenStream(
tokens: string[],
delayMs = 0
): AsyncGenerator<string, void, undefined> {
for (const token of tokens) {
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
yield token;
}
}
/**
* Helper: create a token stream that throws an error mid-stream
*/
async function* createErrorStream(
tokens: string[],
errorAfter: number
): AsyncGenerator<string, void, undefined> {
let count = 0;
for (const token of tokens) {
if (count >= errorAfter) {
throw new Error("LLM provider connection lost");
}
yield token;
count++;
}
}
describe("MatrixStreamingService", () => {
let service: MatrixStreamingService;
beforeEach(async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixStreamingService,
{
provide: MatrixService,
useValue: mockMatrixService,
},
],
}).compile();
service = module.get<MatrixStreamingService>(MatrixStreamingService);
// Clear all mocks
vi.clearAllMocks();
// Re-apply default mock returns after clearing
mockMatrixService.isConnected.mockReturnValue(true);
mockMatrixService.getClient.mockReturnValue(mockClient);
mockClient.sendMessage.mockResolvedValue("$initial-event-id");
mockClient.sendEvent.mockResolvedValue("$edit-event-id");
mockClient.setTyping.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
describe("editMessage", () => {
it("should send a m.replace event to edit an existing message", async () => {
await service.editMessage("!room:example.com", "$original-event-id", "Updated content");
expect(mockClient.sendEvent).toHaveBeenCalledWith("!room:example.com", "m.room.message", {
"m.new_content": {
msgtype: "m.text",
body: "Updated content",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$original-event-id",
},
// Fallback for clients that don't support edits
msgtype: "m.text",
body: "* Updated content",
});
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
await expect(
service.editMessage("!room:example.com", "$event-id", "content")
).rejects.toThrow("Matrix client is not connected");
});
it("should throw error when client is null", async () => {
mockMatrixService.getClient.mockReturnValue(null);
await expect(
service.editMessage("!room:example.com", "$event-id", "content")
).rejects.toThrow("Matrix client is not connected");
});
});
describe("setTypingIndicator", () => {
it("should call client.setTyping with true and timeout", async () => {
await service.setTypingIndicator("!room:example.com", true);
expect(mockClient.setTyping).toHaveBeenCalledWith("!room:example.com", true, 30000);
});
it("should call client.setTyping with false to clear indicator", async () => {
await service.setTypingIndicator("!room:example.com", false);
expect(mockClient.setTyping).toHaveBeenCalledWith("!room:example.com", false, undefined);
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
await expect(service.setTypingIndicator("!room:example.com", true)).rejects.toThrow(
"Matrix client is not connected"
);
});
});
describe("sendStreamingMessage", () => {
it("should send an initial message and return the event ID", async () => {
const eventId = await service.sendStreamingMessage("!room:example.com", "Thinking...");
expect(eventId).toBe("$initial-event-id");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!room:example.com", {
msgtype: "m.text",
body: "Thinking...",
});
});
it("should send a thread message when threadId is provided", async () => {
const eventId = await service.sendStreamingMessage(
"!room:example.com",
"Thinking...",
"$thread-root-id"
);
expect(eventId).toBe("$initial-event-id");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!room:example.com", {
msgtype: "m.text",
body: "Thinking...",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$thread-root-id",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$thread-root-id",
},
},
});
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
await expect(service.sendStreamingMessage("!room:example.com", "Test")).rejects.toThrow(
"Matrix client is not connected"
);
});
});
describe("streamResponse", () => {
it("should send initial 'Thinking...' message and start typing indicator", async () => {
vi.useRealTimers();
const tokens = ["Hello", " world"];
const stream = createTokenStream(tokens);
await service.streamResponse("!room:example.com", stream);
// Should have sent initial message
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
msgtype: "m.text",
body: "Thinking...",
})
);
// Should have started typing indicator
expect(mockClient.setTyping).toHaveBeenCalledWith("!room:example.com", true, 30000);
});
it("should use custom initial message when provided", async () => {
vi.useRealTimers();
const tokens = ["Hi"];
const stream = createTokenStream(tokens);
const options: StreamResponseOptions = { initialMessage: "Processing..." };
await service.streamResponse("!room:example.com", stream, options);
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
body: "Processing...",
})
);
});
it("should edit message with accumulated tokens on completion", async () => {
vi.useRealTimers();
const tokens = ["Hello", " ", "world", "!"];
const stream = createTokenStream(tokens);
await service.streamResponse("!room:example.com", stream);
// The final edit should contain the full accumulated text
const sendEventCalls = mockClient.sendEvent.mock.calls;
const lastEditCall = sendEventCalls[sendEventCalls.length - 1];
expect(lastEditCall).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(lastEditCall[2]["m.new_content"].body).toBe("Hello world!");
});
it("should clear typing indicator on completion", async () => {
vi.useRealTimers();
const tokens = ["Done"];
const stream = createTokenStream(tokens);
await service.streamResponse("!room:example.com", stream);
// Last setTyping call should be false
const typingCalls = mockClient.setTyping.mock.calls;
const lastTypingCall = typingCalls[typingCalls.length - 1];
expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);
});
it("should rate-limit edits to at most one every 500ms", async () => {
vi.useRealTimers();
// Send tokens with small delays - all within one 500ms window
const tokens = ["a", "b", "c", "d", "e"];
const stream = createTokenStream(tokens, 50); // 50ms between tokens = 250ms total
await service.streamResponse("!room:example.com", stream);
// With 250ms total streaming time (5 tokens * 50ms), all tokens arrive
// within one 500ms window. We expect at most 1 intermediate edit + 1 final edit,
// or just the final edit. The key point is that there should NOT be 5 separate edits.
const editCalls = mockClient.sendEvent.mock.calls.filter(
(call) => call[1] === "m.room.message"
);
// Should have fewer edits than tokens (rate limiting in effect)
expect(editCalls.length).toBeLessThanOrEqual(2);
// Should have at least the final edit
expect(editCalls.length).toBeGreaterThanOrEqual(1);
});
it("should handle errors gracefully and edit message with error notice", async () => {
vi.useRealTimers();
const stream = createErrorStream(["Hello", " ", "world"], 2);
await service.streamResponse("!room:example.com", stream);
// Should edit message with error content
const sendEventCalls = mockClient.sendEvent.mock.calls;
const lastEditCall = sendEventCalls[sendEventCalls.length - 1];
expect(lastEditCall).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const finalBody = lastEditCall[2]["m.new_content"].body as string;
expect(finalBody).toContain("error");
// Should clear typing on error
const typingCalls = mockClient.setTyping.mock.calls;
const lastTypingCall = typingCalls[typingCalls.length - 1];
expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);
});
it("should include token usage in final message when provided", async () => {
vi.useRealTimers();
const tokens = ["Hello"];
const stream = createTokenStream(tokens);
const options: StreamResponseOptions = {
showTokenUsage: true,
tokenUsage: { prompt: 10, completion: 5, total: 15 },
};
await service.streamResponse("!room:example.com", stream, options);
const sendEventCalls = mockClient.sendEvent.mock.calls;
const lastEditCall = sendEventCalls[sendEventCalls.length - 1];
expect(lastEditCall).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const finalBody = lastEditCall[2]["m.new_content"].body as string;
expect(finalBody).toContain("15");
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
const stream = createTokenStream(["test"]);
await expect(service.streamResponse("!room:example.com", stream)).rejects.toThrow(
"Matrix client is not connected"
);
});
it("should handle empty token stream", async () => {
vi.useRealTimers();
const stream = createTokenStream([]);
await service.streamResponse("!room:example.com", stream);
// Should still send initial message
expect(mockClient.sendMessage).toHaveBeenCalled();
// Should edit with empty/no-content message
const sendEventCalls = mockClient.sendEvent.mock.calls;
expect(sendEventCalls.length).toBeGreaterThanOrEqual(1);
// Should clear typing
const typingCalls = mockClient.setTyping.mock.calls;
const lastTypingCall = typingCalls[typingCalls.length - 1];
expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);
});
it("should support thread context in streamResponse", async () => {
vi.useRealTimers();
const tokens = ["Reply"];
const stream = createTokenStream(tokens);
const options: StreamResponseOptions = { threadId: "$thread-root" };
await service.streamResponse("!room:example.com", stream, options);
// Initial message should include thread relation
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
"m.relates_to": expect.objectContaining({
rel_type: "m.thread",
event_id: "$thread-root",
}),
})
);
});
it("should perform multiple edits for long-running streams", async () => {
vi.useRealTimers();
// Create tokens with 200ms delays - total ~2000ms, should get multiple edit windows
const tokens = Array.from({ length: 10 }, (_, i) => `token${String(i)} `);
const stream = createTokenStream(tokens, 200);
await service.streamResponse("!room:example.com", stream);
// With 10 tokens at 200ms each = 2000ms total, at 500ms intervals
// we expect roughly 3-4 intermediate edits + 1 final = 4-5 total
const editCalls = mockClient.sendEvent.mock.calls.filter(
(call) => call[1] === "m.room.message"
);
// Should have multiple edits (at least 2) but far fewer than 10
expect(editCalls.length).toBeGreaterThanOrEqual(2);
expect(editCalls.length).toBeLessThanOrEqual(8);
});
});
});

View File

@@ -1,248 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import type { MatrixClient } from "matrix-bot-sdk";
import { MatrixService } from "./matrix.service";
/**
* Options for the streamResponse method
*/
export interface StreamResponseOptions {
/** Custom initial message (defaults to "Thinking...") */
initialMessage?: string;
/** Thread root event ID for threaded responses */
threadId?: string;
/** Whether to show token usage in the final message */
showTokenUsage?: boolean;
/** Token usage stats to display in the final message */
tokenUsage?: { prompt: number; completion: number; total: number };
}
/**
* Matrix message content for m.room.message events
*/
interface MatrixMessageContent {
msgtype: string;
body: string;
"m.new_content"?: {
msgtype: string;
body: string;
};
"m.relates_to"?: {
rel_type: string;
event_id: string;
is_falling_back?: boolean;
"m.in_reply_to"?: {
event_id: string;
};
};
}
/** Minimum interval between message edits (milliseconds) */
const EDIT_INTERVAL_MS = 500;
/** Typing indicator timeout (milliseconds) */
const TYPING_TIMEOUT_MS = 30000;
/**
* Matrix Streaming Service
*
* Provides streaming AI response capabilities for Matrix rooms using
* incremental message edits. Tokens from an LLM are buffered and the
* response message is edited at rate-limited intervals, providing a
* smooth streaming experience without excessive API calls.
*
* Key features:
* - Rate-limited edits (max every 500ms)
* - Typing indicator management during generation
* - Graceful error handling with user-visible error notices
* - Thread support for contextual responses
* - LLM-agnostic design via AsyncIterable<string> token stream
*/
@Injectable()
export class MatrixStreamingService {
private readonly logger = new Logger(MatrixStreamingService.name);
constructor(private readonly matrixService: MatrixService) {}
/**
* Edit an existing Matrix message using the m.replace relation.
*
* Sends a new event that replaces the content of an existing message.
* Includes fallback content for clients that don't support edits.
*
* @param roomId - The Matrix room ID
* @param eventId - The original event ID to replace
* @param newContent - The updated message text
*/
async editMessage(roomId: string, eventId: string, newContent: string): Promise<void> {
const client = this.getClientOrThrow();
const editContent: MatrixMessageContent = {
"m.new_content": {
msgtype: "m.text",
body: newContent,
},
"m.relates_to": {
rel_type: "m.replace",
event_id: eventId,
},
// Fallback for clients that don't support edits
msgtype: "m.text",
body: `* ${newContent}`,
};
await client.sendEvent(roomId, "m.room.message", editContent);
}
/**
* Set the typing indicator for the bot in a room.
*
* @param roomId - The Matrix room ID
* @param typing - Whether the bot is typing
*/
async setTypingIndicator(roomId: string, typing: boolean): Promise<void> {
const client = this.getClientOrThrow();
await client.setTyping(roomId, typing, typing ? TYPING_TIMEOUT_MS : undefined);
}
/**
* Send an initial message for streaming, optionally in a thread.
*
* Returns the event ID of the sent message, which can be used for
* subsequent edits via editMessage.
*
* @param roomId - The Matrix room ID
* @param content - The initial message content
* @param threadId - Optional thread root event ID
* @returns The event ID of the sent message
*/
async sendStreamingMessage(roomId: string, content: string, threadId?: string): Promise<string> {
const client = this.getClientOrThrow();
const messageContent: MatrixMessageContent = {
msgtype: "m.text",
body: content,
};
if (threadId) {
messageContent["m.relates_to"] = {
rel_type: "m.thread",
event_id: threadId,
is_falling_back: true,
"m.in_reply_to": {
event_id: threadId,
},
};
}
const eventId: string = await client.sendMessage(roomId, messageContent);
return eventId;
}
/**
* Stream an AI response to a Matrix room using incremental message edits.
*
* This is the main streaming method. It:
* 1. Sends an initial "Thinking..." message
* 2. Starts the typing indicator
* 3. Buffers incoming tokens from the async iterable
* 4. Edits the message every 500ms with accumulated text
* 5. On completion: sends a final clean edit, clears typing
* 6. On error: edits message with error notice, clears typing
*
* @param roomId - The Matrix room ID
* @param tokenStream - AsyncIterable that yields string tokens
* @param options - Optional configuration for the stream
*/
async streamResponse(
roomId: string,
tokenStream: AsyncIterable<string>,
options?: StreamResponseOptions
): Promise<void> {
// Validate connection before starting
this.getClientOrThrow();
const initialMessage = options?.initialMessage ?? "Thinking...";
const threadId = options?.threadId;
// Step 1: Send initial message
const eventId = await this.sendStreamingMessage(roomId, initialMessage, threadId);
// Step 2: Start typing indicator
await this.setTypingIndicator(roomId, true);
// Step 3: Buffer and stream tokens
let accumulatedText = "";
let lastEditTime = 0;
let hasError = false;
try {
for await (const token of tokenStream) {
accumulatedText += token;
const now = Date.now();
const elapsed = now - lastEditTime;
if (elapsed >= EDIT_INTERVAL_MS && accumulatedText.length > 0) {
await this.editMessage(roomId, eventId, accumulatedText);
lastEditTime = now;
}
}
} catch (error: unknown) {
hasError = true;
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.logger.error(`Stream error in room ${roomId}: ${errorMessage}`);
// Edit message to show error
try {
const errorContent = accumulatedText
? `${accumulatedText}\n\n[Streaming error: ${errorMessage}]`
: `[Streaming error: ${errorMessage}]`;
await this.editMessage(roomId, eventId, errorContent);
} catch (editError: unknown) {
this.logger.warn(
`Failed to edit error message in ${roomId}: ${editError instanceof Error ? editError.message : "unknown"}`
);
}
} finally {
// Step 4: Clear typing indicator
try {
await this.setTypingIndicator(roomId, false);
} catch (typingError: unknown) {
this.logger.warn(
`Failed to clear typing indicator in ${roomId}: ${typingError instanceof Error ? typingError.message : "unknown"}`
);
}
}
// Step 5: Final edit with clean output (if no error)
if (!hasError) {
let finalContent = accumulatedText || "(No response generated)";
if (options?.showTokenUsage && options.tokenUsage) {
const { prompt, completion, total } = options.tokenUsage;
finalContent += `\n\n---\nTokens: ${String(total)} (prompt: ${String(prompt)}, completion: ${String(completion)})`;
}
await this.editMessage(roomId, eventId, finalContent);
}
}
/**
* Get the Matrix client from the parent MatrixService, or throw if not connected.
*/
private getClientOrThrow(): MatrixClient {
if (!this.matrixService.isConnected()) {
throw new Error("Matrix client is not connected");
}
const client = this.matrixService.getClient();
if (!client) {
throw new Error("Matrix client is not connected");
}
return client;
}
}

View File

@@ -1,979 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { MatrixService } from "./matrix.service";
import { MatrixRoomService } from "./matrix-room.service";
import { StitcherService } from "../../stitcher/stitcher.service";
import { CommandParserService } from "../parser/command-parser.service";
import { vi, describe, it, expect, beforeEach } from "vitest";
import type { ChatMessage } from "../interfaces";
// Mock matrix-bot-sdk
const mockMessageCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> = [];
const mockEventCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> = [];
const mockClient = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
on: vi
.fn()
.mockImplementation(
(event: string, callback: (roomId: string, evt: Record<string, unknown>) => void) => {
if (event === "room.message") {
mockMessageCallbacks.push(callback);
}
if (event === "room.event") {
mockEventCallbacks.push(callback);
}
}
),
sendMessage: vi.fn().mockResolvedValue("$event-id-123"),
sendEvent: vi.fn().mockResolvedValue("$event-id-456"),
};
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {
start = mockClient.start;
stop = mockClient.stop;
on = mockClient.on;
sendMessage = mockClient.sendMessage;
sendEvent = mockClient.sendEvent;
},
SimpleFsStorageProvider: class MockStorageProvider {
constructor(_filename: string) {
// No-op for testing
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
describe("MatrixService", () => {
let service: MatrixService;
let stitcherService: StitcherService;
let commandParser: CommandParserService;
let matrixRoomService: MatrixRoomService;
const mockStitcherService = {
dispatchJob: vi.fn().mockResolvedValue({
jobId: "test-job-id",
queueName: "main",
status: "PENDING",
}),
trackJobEvent: vi.fn().mockResolvedValue(undefined),
};
const mockMatrixRoomService = {
getWorkspaceForRoom: vi.fn().mockResolvedValue(null),
getRoomForWorkspace: vi.fn().mockResolvedValue(null),
provisionRoom: vi.fn().mockResolvedValue(null),
linkWorkspaceToRoom: vi.fn().mockResolvedValue(undefined),
unlinkWorkspace: vi.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
// Set environment variables for testing
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com";
process.env.MATRIX_CONTROL_ROOM_ID = "!test-room:example.com";
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
// Clear callbacks
mockMessageCallbacks.length = 0;
mockEventCallbacks.length = 0;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
service = module.get<MatrixService>(MatrixService);
stitcherService = module.get<StitcherService>(StitcherService);
commandParser = module.get<CommandParserService>(CommandParserService);
matrixRoomService = module.get(MatrixRoomService) as MatrixRoomService;
// Clear all mocks
vi.clearAllMocks();
});
describe("Connection Management", () => {
it("should connect to Matrix", async () => {
await service.connect();
expect(mockClient.start).toHaveBeenCalled();
});
it("should disconnect from Matrix", async () => {
await service.connect();
await service.disconnect();
expect(mockClient.stop).toHaveBeenCalled();
});
it("should check connection status", async () => {
expect(service.isConnected()).toBe(false);
await service.connect();
expect(service.isConnected()).toBe(true);
await service.disconnect();
expect(service.isConnected()).toBe(false);
});
});
describe("Message Handling", () => {
it("should send a message to a room", async () => {
await service.connect();
await service.sendMessage("!test-room:example.com", "Hello, Matrix!");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "Hello, Matrix!",
});
});
it("should throw error if client is not connected", async () => {
await expect(service.sendMessage("!room:example.com", "Test")).rejects.toThrow(
"Matrix client is not connected"
);
});
});
describe("Thread Management", () => {
it("should create a thread by sending an initial message", async () => {
await service.connect();
const threadId = await service.createThread({
channelId: "!test-room:example.com",
name: "Job #42",
message: "Starting job...",
});
expect(threadId).toBe("$event-id-123");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "[Job #42] Starting job...",
});
});
it("should send a message to a thread with m.thread relation", async () => {
await service.connect();
await service.sendThreadMessage({
threadId: "$root-event-id",
channelId: "!test-room:example.com",
content: "Step completed",
});
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "Step completed",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$root-event-id",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$root-event-id",
},
},
});
});
it("should fall back to controlRoomId when channelId is empty", async () => {
await service.connect();
await service.sendThreadMessage({
threadId: "$root-event-id",
channelId: "",
content: "Fallback message",
});
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "Fallback message",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$root-event-id",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$root-event-id",
},
},
});
});
it("should throw error when creating thread without connection", async () => {
await expect(
service.createThread({
channelId: "!room:example.com",
name: "Test",
message: "Test",
})
).rejects.toThrow("Matrix client is not connected");
});
it("should throw error when sending thread message without connection", async () => {
await expect(
service.sendThreadMessage({
threadId: "$event-id",
channelId: "!room:example.com",
content: "Test",
})
).rejects.toThrow("Matrix client is not connected");
});
});
describe("Command Parsing with shared CommandParserService", () => {
it("should parse @mosaic fix #42 via shared parser", () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix #42",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("fix");
expect(command?.args).toContain("#42");
});
it("should parse !mosaic fix #42 by normalizing to @mosaic for the shared parser", () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "!mosaic fix #42",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("fix");
expect(command?.args).toContain("#42");
});
it("should parse @mosaic status command via shared parser", () => {
const message: ChatMessage = {
id: "msg-2",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic status job-123",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("status");
expect(command?.args).toContain("job-123");
});
it("should parse @mosaic cancel command via shared parser", () => {
const message: ChatMessage = {
id: "msg-3",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic cancel job-456",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("cancel");
});
it("should parse @mosaic help command via shared parser", () => {
const message: ChatMessage = {
id: "msg-6",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic help",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("help");
});
it("should return null for non-command messages", () => {
const message: ChatMessage = {
id: "msg-7",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "Just a regular message",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).toBeNull();
});
it("should return null for messages without @mosaic or !mosaic mention", () => {
const message: ChatMessage = {
id: "msg-8",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "fix 42",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).toBeNull();
});
it("should return null for @mosaic mention without a command", () => {
const message: ChatMessage = {
id: "msg-11",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).toBeNull();
});
});
describe("Event-driven message reception", () => {
it("should ignore messages from the bot itself", async () => {
await service.connect();
const parseCommandSpy = vi.spyOn(commandParser, "parseCommand");
// Simulate a message from the bot
expect(mockMessageCallbacks.length).toBeGreaterThan(0);
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@mosaic-bot:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #42",
},
});
// Should not attempt to parse
expect(parseCommandSpy).not.toHaveBeenCalled();
});
it("should ignore messages in unmapped rooms", async () => {
// MatrixRoomService returns null for unknown rooms
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null);
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!unknown-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #42",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should not dispatch to stitcher
expect(stitcherService.dispatchJob).not.toHaveBeenCalled();
});
it("should process commands in the control room (fallback workspace)", async () => {
// MatrixRoomService returns null, but room matches controlRoomId
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null);
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic help",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should send help message
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands:"),
})
);
});
it("should process commands in rooms mapped via MatrixRoomService", async () => {
// MatrixRoomService resolves the workspace
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("mapped-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!mapped-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #42",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should dispatch with the mapped workspace ID
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "mapped-workspace-id",
})
);
});
it("should handle !mosaic prefix in incoming messages", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "!mosaic help",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should send help message (normalized !mosaic -> @mosaic for parser)
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands:"),
})
);
});
it("should send help text when user tries an unknown command", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic invalidcommand",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should send error/help message (CommandParserService returns help text for unknown actions)
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands"),
})
);
});
it("should ignore non-text messages", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.image",
body: "photo.jpg",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should not attempt any message sending
expect(mockClient.sendMessage).not.toHaveBeenCalled();
});
});
describe("Command Execution", () => {
it("should forward fix command to stitcher and create a thread", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix 42",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: ["42"],
message,
});
expect(stitcherService.dispatchJob).toHaveBeenCalledWith({
workspaceId: "test-workspace-id",
type: "code-task",
priority: 10,
metadata: {
issueNumber: 42,
command: "fix",
channelId: "!test-room:example.com",
threadId: "$event-id-123",
authorId: "@user:example.com",
authorName: "@user:example.com",
},
});
});
it("should handle fix with #-prefixed issue number", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix #42",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: ["#42"],
message,
});
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
issueNumber: 42,
}),
})
);
});
it("should respond with help message", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic help",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "help",
args: [],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands:"),
})
);
});
it("should include retry command in help output", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic help",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "help",
args: [],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("retry"),
})
);
});
it("should send error for fix command without issue number", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: [],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Usage:"),
})
);
});
it("should send error for fix command with non-numeric issue", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix abc",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: ["abc"],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Invalid issue number"),
})
);
});
it("should dispatch fix command with workspace from MatrixRoomService", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("dynamic-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!mapped-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #99",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "dynamic-workspace-id",
metadata: expect.objectContaining({
issueNumber: 99,
}),
})
);
});
});
describe("Configuration", () => {
it("should throw error if MATRIX_HOMESERVER_URL is not set", async () => {
delete process.env.MATRIX_HOMESERVER_URL;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_HOMESERVER_URL is required");
// Restore for other tests
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
});
it("should throw error if MATRIX_ACCESS_TOKEN is not set", async () => {
delete process.env.MATRIX_ACCESS_TOKEN;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_ACCESS_TOKEN is required");
// Restore for other tests
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
});
it("should throw error if MATRIX_BOT_USER_ID is not set", async () => {
delete process.env.MATRIX_BOT_USER_ID;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_BOT_USER_ID is required");
// Restore for other tests
process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com";
});
it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => {
delete process.env.MATRIX_WORKSPACE_ID;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_WORKSPACE_ID is required");
// Restore for other tests
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
});
it("should use configured workspace ID from environment", async () => {
const testWorkspaceId = "configured-workspace-456";
process.env.MATRIX_WORKSPACE_ID = testWorkspaceId;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix 42",
timestamp: new Date(),
};
await newService.connect();
await newService.handleCommand({
command: "fix",
args: ["42"],
message,
});
expect(mockStitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: testWorkspaceId,
})
);
// Restore for other tests
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
});
});
describe("Error Logging Security", () => {
it("should sanitize sensitive data in error logs", async () => {
const loggerErrorSpy = vi.spyOn(
(service as Record<string, unknown>)["logger"] as { error: (...args: unknown[]) => void },
"error"
);
await service.connect();
// Trigger room.event handler with null event to exercise error path
expect(mockEventCallbacks.length).toBeGreaterThan(0);
mockEventCallbacks[0]?.("!room:example.com", null as unknown as Record<string, unknown>);
// Verify error was logged
expect(loggerErrorSpy).toHaveBeenCalled();
// Get the logged error
const loggedArgs = loggerErrorSpy.mock.calls[0];
const loggedError = loggedArgs?.[1] as Record<string, unknown>;
// Verify non-sensitive error info is preserved
expect(loggedError).toBeDefined();
expect((loggedError as { message: string }).message).toBe("Received null event from Matrix");
});
it("should not include access token in error output", () => {
// Verify the access token is stored privately and not exposed
const serviceAsRecord = service as unknown as Record<string, unknown>;
// The accessToken should exist but should not appear in any public-facing method output
expect(serviceAsRecord["accessToken"]).toBe("test-access-token");
// Verify isConnected does not leak token
const connected = service.isConnected();
expect(String(connected)).not.toContain("test-access-token");
});
});
describe("MatrixRoomService reverse lookup", () => {
it("should call getWorkspaceForRoom when processing messages", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("resolved-workspace");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!some-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic help",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
expect(matrixRoomService.getWorkspaceForRoom).toHaveBeenCalledWith("!some-room:example.com");
});
it("should fall back to control room workspace when MatrixRoomService returns null", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null);
await service.connect();
const callback = mockMessageCallbacks[0];
// Send to the control room (fallback path)
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #10",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should dispatch with the env-configured workspace
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "test-workspace-id",
})
);
});
});
});

View File

@@ -1,649 +0,0 @@
import { Injectable, Logger, Optional, Inject } from "@nestjs/common";
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
import { StitcherService } from "../../stitcher/stitcher.service";
import { CommandParserService } from "../parser/command-parser.service";
import { CommandAction } from "../parser/command.interface";
import type { ParsedCommand } from "../parser/command.interface";
import { MatrixRoomService } from "./matrix-room.service";
import { sanitizeForLogging } from "../../common/utils";
import type {
IChatProvider,
ChatMessage,
ChatCommand,
ThreadCreateOptions,
ThreadMessageOptions,
} from "../interfaces";
/**
* Matrix room message event content
*/
interface MatrixMessageContent {
msgtype: string;
body: string;
"m.relates_to"?: MatrixRelatesTo;
}
/**
* Matrix relationship metadata for threads (MSC3440)
*/
interface MatrixRelatesTo {
rel_type: string;
event_id: string;
is_falling_back?: boolean;
"m.in_reply_to"?: {
event_id: string;
};
}
/**
* Matrix room event structure
*/
interface MatrixRoomEvent {
event_id: string;
sender: string;
origin_server_ts: number;
content: MatrixMessageContent;
}
/**
* Matrix Service - Matrix chat platform integration
*
* Responsibilities:
* - Connect to Matrix via access token
* - Listen for commands in mapped rooms (via MatrixRoomService)
* - Parse commands using shared CommandParserService
* - Forward commands to stitcher
* - Receive status updates from herald
* - Post updates to threads (MSC3440)
*/
@Injectable()
export class MatrixService implements IChatProvider {
private readonly logger = new Logger(MatrixService.name);
private client: MatrixClient | null = null;
private connected = false;
private readonly homeserverUrl: string;
private readonly accessToken: string;
private readonly botUserId: string;
private readonly controlRoomId: string;
private readonly workspaceId: string;
constructor(
private readonly stitcherService: StitcherService,
@Optional()
@Inject(CommandParserService)
private readonly commandParser: CommandParserService | null,
@Optional()
@Inject(MatrixRoomService)
private readonly matrixRoomService: MatrixRoomService | null
) {
this.homeserverUrl = process.env.MATRIX_HOMESERVER_URL ?? "";
this.accessToken = process.env.MATRIX_ACCESS_TOKEN ?? "";
this.botUserId = process.env.MATRIX_BOT_USER_ID ?? "";
this.controlRoomId = process.env.MATRIX_CONTROL_ROOM_ID ?? "";
this.workspaceId = process.env.MATRIX_WORKSPACE_ID ?? "";
}
/**
* Connect to Matrix homeserver
*/
async connect(): Promise<void> {
if (!this.homeserverUrl) {
throw new Error("MATRIX_HOMESERVER_URL is required");
}
if (!this.accessToken) {
throw new Error("MATRIX_ACCESS_TOKEN is required");
}
if (!this.workspaceId) {
throw new Error("MATRIX_WORKSPACE_ID is required");
}
if (!this.botUserId) {
throw new Error("MATRIX_BOT_USER_ID is required");
}
this.logger.log("Connecting to Matrix...");
const storage = new SimpleFsStorageProvider("matrix-bot-storage.json");
this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage);
// Auto-join rooms when invited
AutojoinRoomsMixin.setupOnClient(this.client);
// Setup event handlers
this.setupEventHandlers();
// Start syncing
await this.client.start();
this.connected = true;
this.logger.log(`Matrix bot connected as ${this.botUserId}`);
}
/**
* Setup event handlers for Matrix client
*/
private setupEventHandlers(): void {
if (!this.client) return;
this.client.on("room.message", (roomId: string, event: MatrixRoomEvent) => {
// Ignore messages from the bot itself
if (event.sender === this.botUserId) return;
// Only handle text messages
if (event.content.msgtype !== "m.text") return;
this.handleRoomMessage(roomId, event).catch((error: unknown) => {
this.logger.error(
`Error handling room message in ${roomId}:`,
error instanceof Error ? error.message : error
);
});
});
this.client.on("room.event", (_roomId: string, event: MatrixRoomEvent | null) => {
// Handle errors emitted as events
if (!event) {
const error = new Error("Received null event from Matrix");
const sanitizedError = sanitizeForLogging(error);
this.logger.error("Matrix client error:", sanitizedError);
}
});
}
/**
* Handle an incoming room message.
*
* Resolves the workspace for the room (via MatrixRoomService or fallback
* to the control room), then delegates to the shared CommandParserService
* for platform-agnostic command parsing and dispatches the result.
*/
private async handleRoomMessage(roomId: string, event: MatrixRoomEvent): Promise<void> {
// Resolve workspace: try MatrixRoomService first, fall back to control room
let resolvedWorkspaceId: string | null = null;
if (this.matrixRoomService) {
resolvedWorkspaceId = await this.matrixRoomService.getWorkspaceForRoom(roomId);
}
// Fallback: if the room is the configured control room, use the env workspace
if (!resolvedWorkspaceId && roomId === this.controlRoomId) {
resolvedWorkspaceId = this.workspaceId;
}
// If room is not mapped to any workspace, ignore the message
if (!resolvedWorkspaceId) {
return;
}
const messageContent = event.content.body;
// Build ChatMessage for interface compatibility
const chatMessage: ChatMessage = {
id: event.event_id,
channelId: roomId,
authorId: event.sender,
authorName: event.sender,
content: messageContent,
timestamp: new Date(event.origin_server_ts),
...(event.content["m.relates_to"]?.rel_type === "m.thread" && {
threadId: event.content["m.relates_to"].event_id,
}),
};
// Use shared CommandParserService if available
if (this.commandParser) {
// Normalize !mosaic to @mosaic for the shared parser
const normalizedContent = messageContent.replace(/^!mosaic/i, "@mosaic");
const result = this.commandParser.parseCommand(normalizedContent);
if (result.success) {
await this.handleParsedCommand(result.command, chatMessage, resolvedWorkspaceId);
} else if (normalizedContent.toLowerCase().startsWith("@mosaic")) {
// The user tried to use a command but it failed to parse -- send help
await this.sendMessage(roomId, result.error.help ?? result.error.message);
}
return;
}
// Fallback: use the built-in parseCommand if CommandParserService not injected
const command = this.parseCommand(chatMessage);
if (command) {
await this.handleCommand(command);
}
}
/**
* Handle a command parsed by the shared CommandParserService.
*
* Routes the ParsedCommand to the appropriate handler, passing
* along workspace context for job dispatch.
*/
private async handleParsedCommand(
parsed: ParsedCommand,
message: ChatMessage,
workspaceId: string
): Promise<void> {
this.logger.log(
`Handling command: ${parsed.action} from ${message.authorName} in workspace ${workspaceId}`
);
switch (parsed.action) {
case CommandAction.FIX:
await this.handleFixCommand(parsed.rawArgs, message, workspaceId);
break;
case CommandAction.STATUS:
await this.handleStatusCommand(parsed.rawArgs, message);
break;
case CommandAction.CANCEL:
await this.handleCancelCommand(parsed.rawArgs, message);
break;
case CommandAction.VERBOSE:
await this.handleVerboseCommand(parsed.rawArgs, message);
break;
case CommandAction.QUIET:
await this.handleQuietCommand(parsed.rawArgs, message);
break;
case CommandAction.HELP:
await this.handleHelpCommand(parsed.rawArgs, message);
break;
case CommandAction.RETRY:
await this.handleRetryCommand(parsed.rawArgs, message);
break;
default:
await this.sendMessage(
message.channelId,
`Unknown command. Type \`@mosaic help\` or \`!mosaic help\` for available commands.`
);
}
}
/**
* Disconnect from Matrix
*/
disconnect(): Promise<void> {
this.logger.log("Disconnecting from Matrix...");
this.connected = false;
if (this.client) {
this.client.stop();
}
return Promise.resolve();
}
/**
* Check if the provider is connected
*/
isConnected(): boolean {
return this.connected;
}
/**
* Get the underlying MatrixClient instance.
*
* Used by MatrixStreamingService for low-level operations
* (message edits, typing indicators) that require direct client access.
*
* @returns The MatrixClient instance, or null if not connected
*/
getClient(): MatrixClient | null {
return this.client;
}
/**
* Send a message to a room
*/
async sendMessage(roomId: string, content: string): Promise<void> {
if (!this.client) {
throw new Error("Matrix client is not connected");
}
const messageContent: MatrixMessageContent = {
msgtype: "m.text",
body: content,
};
await this.client.sendMessage(roomId, messageContent);
}
/**
* Create a thread for job updates (MSC3440)
*
* Matrix threads are created by sending an initial message
* and then replying with m.thread relation. The initial
* message event ID becomes the thread root.
*/
async createThread(options: ThreadCreateOptions): Promise<string> {
if (!this.client) {
throw new Error("Matrix client is not connected");
}
const { channelId, name, message } = options;
// Send the initial message that becomes the thread root
const initialContent: MatrixMessageContent = {
msgtype: "m.text",
body: `[${name}] ${message}`,
};
const eventId = await this.client.sendMessage(channelId, initialContent);
return eventId;
}
/**
* Send a message to a thread (MSC3440)
*
* Uses m.thread relation to associate the message with the thread root event.
*/
async sendThreadMessage(options: ThreadMessageOptions): Promise<void> {
if (!this.client) {
throw new Error("Matrix client is not connected");
}
const { threadId, channelId, content } = options;
// Use the channelId from options (threads are room-scoped), fall back to control room
const roomId = channelId || this.controlRoomId;
const threadContent: MatrixMessageContent = {
msgtype: "m.text",
body: content,
"m.relates_to": {
rel_type: "m.thread",
event_id: threadId,
is_falling_back: true,
"m.in_reply_to": {
event_id: threadId,
},
},
};
await this.client.sendMessage(roomId, threadContent);
}
/**
* Parse a command from a message (IChatProvider interface).
*
* Delegates to the shared CommandParserService when available,
* falling back to built-in parsing for backwards compatibility.
*/
parseCommand(message: ChatMessage): ChatCommand | null {
const { content } = message;
// Try shared parser first
if (this.commandParser) {
const normalizedContent = content.replace(/^!mosaic/i, "@mosaic");
const result = this.commandParser.parseCommand(normalizedContent);
if (result.success) {
return {
command: result.command.action,
args: result.command.rawArgs,
message,
};
}
return null;
}
// Fallback: built-in parsing for when CommandParserService is not injected
const lowerContent = content.toLowerCase();
if (!lowerContent.includes("@mosaic") && !lowerContent.includes("!mosaic")) {
return null;
}
const parts = content.trim().split(/\s+/);
const mosaicIndex = parts.findIndex(
(part) => part.toLowerCase().includes("@mosaic") || part.toLowerCase().includes("!mosaic")
);
if (mosaicIndex === -1 || mosaicIndex === parts.length - 1) {
return null;
}
const commandPart = parts[mosaicIndex + 1];
if (!commandPart) {
return null;
}
const command = commandPart.toLowerCase();
const args = parts.slice(mosaicIndex + 2);
const validCommands = ["fix", "status", "cancel", "verbose", "quiet", "help"];
if (!validCommands.includes(command)) {
return null;
}
return {
command,
args,
message,
};
}
/**
* Handle a parsed command (ChatCommand format, used by fallback path)
*/
async handleCommand(command: ChatCommand): Promise<void> {
const { command: cmd, args, message } = command;
this.logger.log(
`Handling command: ${cmd} with args: ${args.join(", ")} from ${message.authorName}`
);
switch (cmd) {
case "fix":
await this.handleFixCommand(args, message, this.workspaceId);
break;
case "status":
await this.handleStatusCommand(args, message);
break;
case "cancel":
await this.handleCancelCommand(args, message);
break;
case "verbose":
await this.handleVerboseCommand(args, message);
break;
case "quiet":
await this.handleQuietCommand(args, message);
break;
case "help":
await this.handleHelpCommand(args, message);
break;
default:
await this.sendMessage(
message.channelId,
`Unknown command: ${cmd}. Type \`@mosaic help\` or \`!mosaic help\` for available commands.`
);
}
}
/**
* Handle fix command - Start a job for an issue
*/
private async handleFixCommand(
args: string[],
message: ChatMessage,
workspaceId?: string
): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic fix <issue-number>` or `!mosaic fix <issue-number>`"
);
return;
}
// Parse issue number: handle both "#42" and "42" formats
const issueArg = args[0].replace(/^#/, "");
const issueNumber = parseInt(issueArg, 10);
if (isNaN(issueNumber)) {
await this.sendMessage(
message.channelId,
"Invalid issue number. Please provide a numeric issue number."
);
return;
}
const targetWorkspaceId = workspaceId ?? this.workspaceId;
// Create thread for job updates
const threadId = await this.createThread({
channelId: message.channelId,
name: `Job #${String(issueNumber)}`,
message: `Starting job for issue #${String(issueNumber)}...`,
});
// Dispatch job to stitcher
try {
const result = await this.stitcherService.dispatchJob({
workspaceId: targetWorkspaceId,
type: "code-task",
priority: 10,
metadata: {
issueNumber,
command: "fix",
channelId: message.channelId,
threadId: threadId,
authorId: message.authorId,
authorName: message.authorName,
},
});
// Send confirmation to thread
await this.sendThreadMessage({
threadId,
channelId: message.channelId,
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
this.logger.error(
`Failed to dispatch job for issue #${String(issueNumber)}: ${errorMessage}`
);
await this.sendThreadMessage({
threadId,
channelId: message.channelId,
content: `Failed to start job: ${errorMessage}`,
});
}
}
/**
* Handle status command - Get job status
*/
private async handleStatusCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic status <job-id>` or `!mosaic status <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement job status retrieval from stitcher
await this.sendMessage(
message.channelId,
`Status command not yet implemented for job: ${jobId}`
);
}
/**
* Handle cancel command - Cancel a running job
*/
private async handleCancelCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic cancel <job-id>` or `!mosaic cancel <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement job cancellation in stitcher
await this.sendMessage(
message.channelId,
`Cancel command not yet implemented for job: ${jobId}`
);
}
/**
* Handle retry command - Retry a failed job
*/
private async handleRetryCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic retry <job-id>` or `!mosaic retry <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement job retry in stitcher
await this.sendMessage(
message.channelId,
`Retry command not yet implemented for job: ${jobId}`
);
}
/**
* Handle verbose command - Stream full logs to thread
*/
private async handleVerboseCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic verbose <job-id>` or `!mosaic verbose <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement verbose logging
await this.sendMessage(message.channelId, `Verbose mode not yet implemented for job: ${jobId}`);
}
/**
* Handle quiet command - Reduce notifications
*/
private async handleQuietCommand(_args: string[], message: ChatMessage): Promise<void> {
// TODO: Implement quiet mode
await this.sendMessage(
message.channelId,
"Quiet mode not yet implemented. Currently showing milestone updates only."
);
}
/**
* Handle help command - Show available commands
*/
private async handleHelpCommand(_args: string[], message: ChatMessage): Promise<void> {
const helpMessage = `
**Available commands:**
\`@mosaic fix <issue>\` or \`!mosaic fix <issue>\` - Start job for issue
\`@mosaic status <job>\` or \`!mosaic status <job>\` - Get job status
\`@mosaic cancel <job>\` or \`!mosaic cancel <job>\` - Cancel running job
\`@mosaic retry <job>\` or \`!mosaic retry <job>\` - Retry failed job
\`@mosaic verbose <job>\` or \`!mosaic verbose <job>\` - Stream full logs to thread
\`@mosaic quiet\` or \`!mosaic quiet\` - Reduce notifications
\`@mosaic help\` or \`!mosaic help\` - Show this help message
**Noise Management:**
- Main room: Low verbosity (milestones only)
- Job threads: Medium verbosity (step completions)
- DMs: Configurable per user
`.trim();
await this.sendMessage(message.channelId, helpMessage);
}
}

View File

@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser; user?: AuthenticatedUser;
} }
@Controller("v1/csrf") @Controller("api/v1/csrf")
export class CsrfController { export class CsrfController {
constructor(private readonly csrfService: CsrfService) {} constructor(private readonly csrfService: CsrfService) {}

View File

@@ -113,24 +113,34 @@ describe("ApiKeyGuard", () => {
const validApiKey = "test-api-key-12345"; const validApiKey = "test-api-key-12345";
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey); vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
// Verify that same-length keys are compared properly (exercises timingSafeEqual path) const startTime = Date.now();
// and different-length keys are rejected before comparison const context1 = createMockExecutionContext({
const sameLength = createMockExecutionContext({ "x-api-key": "wrong-key-short",
"x-api-key": "test-api-key-12344", // Same length, one char different
});
const differentLength = createMockExecutionContext({
"x-api-key": "short", // Different length
}); });
// Both should throw, proving the comparison logic handles both cases try {
expect(() => guard.canActivate(sameLength)).toThrow("Invalid API key"); guard.canActivate(context1);
expect(() => guard.canActivate(differentLength)).toThrow("Invalid API key"); } catch {
// Expected to fail
}
const shortKeyTime = Date.now() - startTime;
// Correct key should pass const startTime2 = Date.now();
const correct = createMockExecutionContext({ const context2 = createMockExecutionContext({
"x-api-key": validApiKey, "x-api-key": "test-api-key-12344", // Very close to correct key
}); });
expect(guard.canActivate(correct)).toBe(true);
try {
guard.canActivate(context2);
} catch {
// Expected to fail
}
const longKeyTime = Date.now() - startTime2;
// Times should be similar (within 10ms) to prevent timing attacks
// Note: This is a simplified test; real timing attack prevention
// is handled by crypto.timingSafeEqual
expect(Math.abs(shortKeyTime - longKeyTime)).toBeLessThan(10);
}); });
}); });
}); });

View File

@@ -174,19 +174,17 @@ describe("CsrfGuard", () => {
}); });
describe("Session binding validation", () => { describe("Session binding validation", () => {
it("should allow when user context is not yet available (global guard ordering)", () => { it("should reject when user is not authenticated", () => {
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
// so request.user may not be populated. Double-submit cookie match
// is sufficient protection in this case.
const token = generateValidToken("user-123"); const token = generateValidToken("user-123");
const context = createContext( const context = createContext(
"POST", "POST",
{ "csrf-token": token }, { "csrf-token": token },
{ "x-csrf-token": token }, { "x-csrf-token": token },
false false
// No userId - AuthGuard hasn't run yet // No userId - unauthenticated
); );
expect(guard.canActivate(context)).toBe(true); expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
}); });
it("should reject token from different session", () => { it("should reject token from different session", () => {

View File

@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
throw new ForbiddenException("CSRF token mismatch"); throw new ForbiddenException("CSRF token mismatch");
} }
// Validate session binding via HMAC when user context is available. // Validate session binding via HMAC
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
// AuthGuard, so request.user may not be populated yet. In that case, the
// double-submit cookie match above is sufficient CSRF protection.
const userId = request.user?.id; const userId = request.user?.id;
if (userId) { if (!userId) {
if (!this.csrfService.validateToken(cookieToken, userId)) { this.logger.warn({
this.logger.warn({ event: "CSRF_NO_USER_CONTEXT",
event: "CSRF_SESSION_BINDING_INVALID",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token not bound to session");
}
} else {
this.logger.debug({
event: "CSRF_SKIP_SESSION_BINDING",
method: request.method, method: request.method,
path: request.path, path: request.path,
reason: "User context not yet available (global guard runs before AuthGuard)", securityEvent: true,
timestamp: new Date().toISOString(),
}); });
throw new ForbiddenException("CSRF validation requires authentication");
}
if (!this.csrfService.validateToken(cookieToken, userId)) {
this.logger.warn({
event: "CSRF_SESSION_BINDING_INVALID",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token not bound to session");
} }
return true; return true;

View File

@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
return paramWorkspaceId; return paramWorkspaceId;
} }
// 3. Check request body (body may be undefined for GET requests despite Express typings) // 3. Check request body
const body = request.body as Record<string, unknown> | undefined; const bodyWorkspaceId = request.body.workspaceId;
if (body && typeof body.workspaceId === "string") { if (typeof bodyWorkspaceId === "string") {
return body.workspaceId; return bodyWorkspaceId;
} }
// 4. Check query string (backward compatibility for existing clients) // 4. Check query string (backward compatibility for existing clients)

View File

@@ -137,13 +137,13 @@ describe("RLS Context Integration", () => {
queries: ["findMany"], queries: ["findMany"],
}); });
// Verify transaction-local set_config calls were made // Verify SET LOCAL was called
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith( expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]), expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
userId userId
); );
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith( expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]), expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
workspaceId workspaceId
); );
}); });

View File

@@ -80,7 +80,7 @@ describe("RlsContextInterceptor", () => {
expect(result).toEqual({ data: "test response" }); expect(result).toEqual({ data: "test response" });
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith( expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]), expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
userId userId
); );
}); });
@@ -111,13 +111,13 @@ describe("RlsContextInterceptor", () => {
// Check that user context was set // Check that user context was set
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith( expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
1, 1,
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]), expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
userId userId
); );
// Check that workspace context was set // Check that workspace context was set
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith( expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
2, 2,
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]), expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
workspaceId workspaceId
); );
}); });

View File

@@ -100,12 +100,12 @@ export class RlsContextInterceptor implements NestInterceptor {
this.prisma this.prisma
.$transaction( .$transaction(
async (tx) => { async (tx) => {
// Use set_config(..., true) so values are transaction-local and parameterized safely. // Set user context (always present for authenticated requests)
// Direct SET LOCAL with bind parameters produces invalid SQL on PostgreSQL. await tx.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
// Set workspace context (if present)
if (workspaceId) { if (workspaceId) {
await tx.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`; await tx.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
} }
// Propagate the transaction client via AsyncLocalStorage // Propagate the transaction client via AsyncLocalStorage

View File

@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
const duration = Date.now() - start; const duration = Date.now() - start;
expect(result.password).toBe("[REDACTED]"); expect(result.password).toBe("[REDACTED]");
expect(duration).toBeLessThan(500); // Should complete in under 500ms expect(duration).toBeLessThan(100); // Should complete in under 100ms
}); });
}); });

View File

@@ -245,7 +245,7 @@ describe("CoordinatorIntegrationController - Rate Limiting", () => {
.set("X-API-Key", "test-coordinator-key"); .set("X-API-Key", "test-coordinator-key");
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
}, 30000); });
}); });
describe("Per-API-Key Rate Limiting", () => { describe("Per-API-Key Rate Limiting", () => {

View File

@@ -15,12 +15,7 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { PrismaClient, CredentialType, CredentialScope } from "@prisma/client"; import { PrismaClient, CredentialType, CredentialScope } from "@prisma/client";
const shouldRunDbIntegrationTests = describe("UserCredential Model", () => {
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
describeFn("UserCredential Model", () => {
let prisma: PrismaClient; let prisma: PrismaClient;
let testUserId: string; let testUserId: string;
let testWorkspaceId: string; let testWorkspaceId: string;
@@ -28,8 +23,8 @@ describeFn("UserCredential Model", () => {
beforeAll(async () => { beforeAll(async () => {
// Note: These tests require a running database // Note: These tests require a running database
// They will be skipped in CI if DATABASE_URL is not set // They will be skipped in CI if DATABASE_URL is not set
if (!shouldRunDbIntegrationTests) { if (!process.env.DATABASE_URL) {
console.warn("Skipping UserCredential model tests (set RUN_DB_TESTS=true and DATABASE_URL)"); console.warn("DATABASE_URL not set, skipping UserCredential model tests");
return; return;
} }

View File

@@ -1,143 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { DashboardController } from "./dashboard.controller";
import { DashboardService } from "./dashboard.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard } from "../common/guards/workspace.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import type { DashboardSummaryDto } from "./dto";
describe("DashboardController", () => {
let controller: DashboardController;
let service: DashboardService;
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
const mockSummary: DashboardSummaryDto = {
metrics: {
activeAgents: 3,
tasksCompleted: 12,
totalTasks: 25,
tasksInProgress: 5,
activeProjects: 4,
errorRate: 2.5,
},
recentActivity: [
{
id: "550e8400-e29b-41d4-a716-446655440010",
action: "CREATED",
entityType: "TASK",
entityId: "550e8400-e29b-41d4-a716-446655440011",
details: { title: "New task" },
userId: "550e8400-e29b-41d4-a716-446655440002",
createdAt: "2026-02-22T12:00:00.000Z",
},
],
activeJobs: [
{
id: "550e8400-e29b-41d4-a716-446655440020",
type: "code-task",
status: "RUNNING",
progressPercent: 45,
createdAt: "2026-02-22T11:00:00.000Z",
updatedAt: "2026-02-22T11:30:00.000Z",
steps: [
{
id: "550e8400-e29b-41d4-a716-446655440030",
name: "Setup",
status: "COMPLETED",
phase: "SETUP",
},
],
},
],
tokenBudget: [
{
model: "agent-1",
used: 5000,
limit: 10000,
},
],
};
const mockDashboardService = {
getSummary: vi.fn(),
};
const mockAuthGuard = {
canActivate: vi.fn(() => true),
};
const mockWorkspaceGuard = {
canActivate: vi.fn(() => true),
};
const mockPermissionGuard = {
canActivate: vi.fn(() => true),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DashboardController],
providers: [
{
provide: DashboardService,
useValue: mockDashboardService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.overrideGuard(WorkspaceGuard)
.useValue(mockWorkspaceGuard)
.overrideGuard(PermissionGuard)
.useValue(mockPermissionGuard)
.compile();
controller = module.get<DashboardController>(DashboardController);
service = module.get<DashboardService>(DashboardService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getSummary", () => {
it("should return dashboard summary for workspace", async () => {
mockDashboardService.getSummary.mockResolvedValue(mockSummary);
const result = await controller.getSummary(mockWorkspaceId);
expect(result).toEqual(mockSummary);
expect(service.getSummary).toHaveBeenCalledWith(mockWorkspaceId);
});
it("should return empty arrays when no data exists", async () => {
const emptySummary: DashboardSummaryDto = {
metrics: {
activeAgents: 0,
tasksCompleted: 0,
totalTasks: 0,
tasksInProgress: 0,
activeProjects: 0,
errorRate: 0,
},
recentActivity: [],
activeJobs: [],
tokenBudget: [],
};
mockDashboardService.getSummary.mockResolvedValue(emptySummary);
const result = await controller.getSummary(mockWorkspaceId);
expect(result).toEqual(emptySummary);
expect(result.metrics.errorRate).toBe(0);
expect(result.recentActivity).toHaveLength(0);
expect(result.activeJobs).toHaveLength(0);
expect(result.tokenBudget).toHaveLength(0);
});
});
});

View File

@@ -1,35 +0,0 @@
import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common";
import { DashboardService } from "./dashboard.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import type { DashboardSummaryDto } from "./dto";
/**
* Controller for dashboard endpoints.
* Returns aggregated summary data for the workspace dashboard.
*
* Guards are applied in order:
* 1. AuthGuard - Verifies user authentication
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
* 3. PermissionGuard - Checks role-based permissions
*/
@Controller("dashboard")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
/**
* GET /api/dashboard/summary
* Returns aggregated metrics, recent activity, active jobs, and token budgets
* Requires: Any workspace member (including GUEST)
*/
@Get("summary")
@RequirePermission(Permission.WORKSPACE_ANY)
async getSummary(@Workspace() workspaceId: string | undefined): Promise<DashboardSummaryDto> {
if (!workspaceId) {
throw new BadRequestException("Workspace context required");
}
return this.dashboardService.getSummary(workspaceId);
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from "@nestjs/common";
import { DashboardController } from "./dashboard.controller";
import { DashboardService } from "./dashboard.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -1,187 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AgentStatus, ProjectStatus, RunnerJobStatus, TaskStatus } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type {
DashboardSummaryDto,
ActiveJobDto,
RecentActivityDto,
TokenBudgetEntryDto,
} from "./dto";
/**
* Service for aggregating dashboard summary data.
* Executes all queries in parallel to minimize latency.
*/
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
/**
* Get aggregated dashboard summary for a workspace
*/
async getSummary(workspaceId: string): Promise<DashboardSummaryDto> {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// Execute all queries in parallel
const [
activeAgents,
tasksCompleted,
totalTasks,
tasksInProgress,
activeProjects,
failedJobsLast24h,
totalJobsLast24h,
recentActivityRows,
activeJobRows,
tokenBudgetRows,
] = await Promise.all([
// Active agents: IDLE, WORKING, WAITING
this.prisma.agent.count({
where: {
workspaceId,
status: { in: [AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.WAITING] },
},
}),
// Tasks completed
this.prisma.task.count({
where: {
workspaceId,
status: TaskStatus.COMPLETED,
},
}),
// Total tasks
this.prisma.task.count({
where: { workspaceId },
}),
// Tasks in progress
this.prisma.task.count({
where: {
workspaceId,
status: TaskStatus.IN_PROGRESS,
},
}),
// Active projects
this.prisma.project.count({
where: {
workspaceId,
status: ProjectStatus.ACTIVE,
},
}),
// Failed jobs in last 24h (for error rate)
this.prisma.runnerJob.count({
where: {
workspaceId,
status: RunnerJobStatus.FAILED,
createdAt: { gte: oneDayAgo },
},
}),
// Total jobs in last 24h (for error rate)
this.prisma.runnerJob.count({
where: {
workspaceId,
createdAt: { gte: oneDayAgo },
},
}),
// Recent activity: last 10 entries
this.prisma.activityLog.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
take: 10,
}),
// Active jobs: PENDING, QUEUED, RUNNING with steps
this.prisma.runnerJob.findMany({
where: {
workspaceId,
status: {
in: [RunnerJobStatus.PENDING, RunnerJobStatus.QUEUED, RunnerJobStatus.RUNNING],
},
},
include: {
steps: {
select: {
id: true,
name: true,
status: true,
phase: true,
},
orderBy: { ordinal: "asc" },
},
},
orderBy: { createdAt: "desc" },
}),
// Token budgets for workspace (active, not yet completed)
this.prisma.tokenBudget.findMany({
where: {
workspaceId,
completedAt: null,
},
select: {
agentId: true,
totalTokensUsed: true,
allocatedTokens: true,
},
}),
]);
// Compute error rate
const errorRate = totalJobsLast24h > 0 ? (failedJobsLast24h / totalJobsLast24h) * 100 : 0;
// Map recent activity
const recentActivity: RecentActivityDto[] = recentActivityRows.map((row) => ({
id: row.id,
action: row.action,
entityType: row.entityType,
entityId: row.entityId,
details: row.details as Record<string, unknown> | null,
userId: row.userId,
createdAt: row.createdAt.toISOString(),
}));
// Map active jobs (RunnerJob lacks updatedAt; use startedAt or createdAt as proxy)
const activeJobs: ActiveJobDto[] = activeJobRows.map((row) => ({
id: row.id,
type: row.type,
status: row.status,
progressPercent: row.progressPercent,
createdAt: row.createdAt.toISOString(),
updatedAt: (row.startedAt ?? row.createdAt).toISOString(),
steps: row.steps.map((step) => ({
id: step.id,
name: step.name,
status: step.status,
phase: step.phase,
})),
}));
// Map token budget entries
const tokenBudget: TokenBudgetEntryDto[] = tokenBudgetRows.map((row) => ({
model: row.agentId,
used: row.totalTokensUsed,
limit: row.allocatedTokens,
}));
return {
metrics: {
activeAgents,
tasksCompleted,
totalTasks,
tasksInProgress,
activeProjects,
errorRate: Math.round(errorRate * 100) / 100,
},
recentActivity,
activeJobs,
tokenBudget,
};
}
}

Some files were not shown because too many files have changed in this diff Show More