Compare commits

..

1 Commits

Author SHA1 Message Date
76c97b238c fix(ci): suppress Next.js bundled tar/minimatch CVEs in trivy scan
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 CVE-2026-26960 (tar) and CVE-2026-26996 (minimatch) to .trivyignore.
These are embedded in next/dist/compiled/ and cannot be fixed via pnpm
overrides — requires upstream Next.js release with updated bundles.

Also add .trivyignore to all pipeline path filters so future changes
to the ignore file trigger CI validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:35:08 -06:00
555 changed files with 8051 additions and 62725 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
@@ -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
@@ -124,9 +116,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) # 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 # These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
@@ -215,9 +204,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 +244,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 +285,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 +292,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)
@@ -353,34 +329,16 @@ RATE_LIMIT_STORAGE=redis
# ====================== # ======================
# Matrix bot integration for chat-based control via Matrix protocol # Matrix bot integration for chat-based control via Matrix protocol
# Requires a Matrix account with an access token for the bot user # Requires a Matrix account with an access token for the bot user
# Set these AFTER deploying Synapse and creating the bot account. # MATRIX_HOMESERVER_URL=https://matrix.example.com
# MATRIX_ACCESS_TOKEN=
# MATRIX_BOT_USER_ID=@mosaic-bot:example.com
# MATRIX_CONTROL_ROOM_ID=!roomid:example.com
# MATRIX_WORKSPACE_ID=your-workspace-uuid
# #
# SECURITY: MATRIX_WORKSPACE_ID must be a valid workspace UUID from your database. # SECURITY: MATRIX_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Matrix commands will execute within this workspace context for proper # All Matrix commands will execute within this workspace context for proper
# multi-tenant isolation. Each Matrix bot instance should be configured for # multi-tenant isolation. Each Matrix bot instance should be configured for
# a single workspace. # 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 +350,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,10 +363,11 @@ 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
@@ -457,9 +405,6 @@ TTS_PREMIUM_URL=http://chatterbox-tts:8881/v1
TTS_FALLBACK_ENABLED=false TTS_FALLBACK_ENABLED=false
TTS_FALLBACK_URL=http://openedai-speech:8000/v1 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 # Speech Service Limits
# Maximum upload file size in bytes (default: 25MB) # Maximum upload file size in bytes (default: 25MB)
SPEECH_MAX_UPLOAD_SIZE=25000000 SPEECH_MAX_UPLOAD_SIZE=25000000
@@ -494,6 +439,28 @@ MOSAIC_TELEMETRY_INSTANCE_ID=your-instance-uuid-here
# Useful for development and debugging telemetry payloads # Useful for development and debugging telemetry payloads
MOSAIC_TELEMETRY_DRY_RUN=false MOSAIC_TELEMETRY_DRY_RUN=false
# ======================
# Matrix Dev Environment (docker-compose.matrix.yml overlay)
# ======================
# These variables configure the local Matrix dev environment.
# Only used when running: docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up
#
# Synapse homeserver
# SYNAPSE_CLIENT_PORT=8008
# SYNAPSE_FEDERATION_PORT=8448
# SYNAPSE_POSTGRES_DB=synapse
# SYNAPSE_POSTGRES_USER=synapse
# SYNAPSE_POSTGRES_PASSWORD=synapse_dev_password
#
# Element Web client
# ELEMENT_PORT=8501
#
# Matrix bridge connection (set after running docker/matrix/scripts/setup-bot.sh)
# MATRIX_HOMESERVER_URL=http://localhost:8008
# MATRIX_ACCESS_TOKEN=<obtained from setup-bot.sh>
# MATRIX_BOT_USER_ID=@mosaic-bot:localhost
# MATRIX_SERVER_NAME=localhost
# ====================== # ======================
# Logging & Debugging # Logging & Debugging
# ====================== # ======================

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

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

2
.npmrc
View File

@@ -1,3 +1 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
supportedArchitectures[libc][]=glibc
supportedArchitectures[cpu][]=x64

View File

@@ -34,9 +34,3 @@ CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs
# 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.
# Cannot build OpenBao from source (large project). Waiting for upstream release. # Cannot build OpenBao from source (large project). Waiting for upstream release.
CVE-2025-68121 # CRITICAL: crypto/tls session resumption CVE-2025-68121 # CRITICAL: crypto/tls session resumption
# === multer CVEs (upstream via @nestjs/platform-express) ===
# multer <2.1.0 — waiting on NestJS to update their dependency
# These are DoS vulnerabilities in file upload handling
GHSA-xf7r-hgr6-v32p # HIGH: DoS via incomplete cleanup
GHSA-v52c-386h-88mc # HIGH: DoS via resource exhaustion

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

236
.woodpecker/api.yml Normal file
View File

@@ -0,0 +1,236 @@
# API Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for @mosaic/api
#
# Triggers on: apps/api/**, packages/**, root configs
# Security chain: source audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/api/**"
- "packages/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "package.json"
- ".woodpecker/api.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
services:
postgres:
image: postgres:17.7-alpine3.22
environment:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
steps:
# === Quality Gates ===
install:
image: *node_image
commands:
- *install_deps
security-audit:
image: *node_image
commands:
- *use_deps
- pnpm audit --audit-level=high
depends_on:
- 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:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" prisma:generate
depends_on:
- install
build-shared:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/shared" build
depends_on:
- install
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" typecheck
depends_on:
- prisma-generate
- build-shared
prisma-migrate:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" prisma migrate deploy
depends_on:
- prisma-generate
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public"
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
commands:
- *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'
depends_on:
- prisma-migrate
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/api
depends_on:
- lint
- typecheck
- test
- security-audit
# === Docker Build & Push ===
docker-build-api:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
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
/kaniko/executor --context . --dockerfile apps/api/Dockerfile $DESTINATIONS
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- build
# === Container Security Scan ===
security-trivy-api:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- docker-build-api
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-api"
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- security-trivy-api

View File

@@ -1,27 +0,0 @@
when:
- event: manual
- event: cron
cron: weekly-base-image
variables:
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
build-base:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
commands:
- *kaniko_setup
- /kaniko/executor
--context .
--dockerfile docker/base.Dockerfile
--destination git.mosaicstack.dev/mosaic/node-base:24-slim
--destination git.mosaicstack.dev/mosaic/node-base:latest
--cache=true
--cache-repo git.mosaicstack.dev/mosaic/node-base/cache

View File

@@ -1,380 +0,0 @@
# Unified CI Pipeline - Mosaic Stack
# Single install, parallel quality gates, sequential deploy
#
# Replaces: api.yml, orchestrator.yml, web.yml
# Keeps: coordinator.yml (Python), infra.yml (separate concerns)
#
# Flow:
# install → security-audit
# → prisma-generate → lint + typecheck (parallel)
# → prisma-migrate → test
# → build (after all gates pass)
# → docker builds (main only, parallel)
# → trivy scans (main only, parallel)
# → package linking (main only)
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/api/**"
- "apps/orchestrator/**"
- "apps/web/**"
- "packages/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "package.json"
- ".woodpecker/ci.yml"
- ".trivyignore"
variables:
- &node_image "node:24-slim"
- &install_deps |
corepack enable
apt-get update && apt-get install -y --no-install-recommends python3 make g++
pnpm config set store-dir /root/.local/share/pnpm/store
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
services:
postgres:
image: postgres:17.7-alpine3.22
environment:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
steps:
# ─── Install (once) ─────────────────────────────────────────
install:
image: *node_image
commands:
- *install_deps
# ─── Security Audit (once) ──────────────────────────────────
security-audit:
image: *node_image
commands:
- *use_deps
- pnpm audit --audit-level=high
depends_on:
- install
# ─── Prisma Generate ────────────────────────────────────────
prisma-generate:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" prisma:generate
depends_on:
- install
# ─── Lint (all packages) ────────────────────────────────────
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint
depends_on:
- prisma-generate
# ─── Typecheck (all packages, parallel with lint) ───────────
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck
depends_on:
- prisma-generate
# ─── Prisma Migrate (test DB) ──────────────────────────────
prisma-migrate:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" prisma migrate deploy
depends_on:
- prisma-generate
# ─── Test (all packages) ───────────────────────────────────
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public"
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
<<: *turbo_env
commands:
- *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 turbo test --filter=@mosaic/orchestrator --filter=@mosaic/web
depends_on:
- prisma-migrate
# ─── Build (all packages) ──────────────────────────────────
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo build
depends_on:
- lint
- typecheck
- test
- security-audit
# ─── Docker Builds (main only, parallel) ───────────────────
docker-build-api:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
docker-build-orchestrator:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
docker-build-web:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
fi
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-web/cache --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
# ─── Container Security Scans (main only) ──────────────────
security-trivy-api:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then SCAN_TAG="$$CI_COMMIT_TAG"; else SCAN_TAG="latest"; fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --ignorefile .trivyignore git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-api
security-trivy-orchestrator:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then SCAN_TAG="$$CI_COMMIT_TAG"; else SCAN_TAG="latest"; fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --ignorefile .trivyignore git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-orchestrator
security-trivy-web:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then SCAN_TAG="$$CI_COMMIT_TAG"; else SCAN_TAG="latest"; fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --ignorefile .trivyignore git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-web
# ─── Package Linking (main only, once) ─────────────────────
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-api"
link_package "stack-orchestrator"
link_package "stack-web"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-api
- security-trivy-orchestrator
- security-trivy-web
# ─── Deploy to Docker Swarm (main only) ─────────────────────
# ─── Deploy to Docker Swarm via Portainer (main only) ─────────────────────
deploy-swarm:
image: alpine:3
environment:
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
SSH_KNOWN_HOSTS:
from_secret: ssh_known_hosts
PORTAINER_URL:
from_secret: portainer_url
PORTAINER_API_KEY:
from_secret: portainer_api_key
commands:
- apk add --no-cache curl openssh-client
- |
set -e
echo "🚀 Deploying to Docker Swarm..."
# Setup SSH for fallback
mkdir -p ~/.ssh
echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Force service updates (images are pulled from public registry)
ssh -o StrictHostKeyChecking=no localadmin@10.1.1.45 \
"docker service update --with-registry-auth --force mosaic-stack-api && \
docker service update --with-registry-auth --force mosaic-stack-web && \
docker service update --with-registry-auth --force mosaic-stack-orchestrator && \
docker service update --with-registry-auth --force mosaic-stack-coordinator && \
echo '✅ All services updated'"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- link-packages

View File

@@ -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

@@ -0,0 +1,193 @@
# Orchestrator Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for @mosaic/orchestrator
#
# Triggers on: apps/orchestrator/**, packages/**, root configs
# Security chain: source audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/orchestrator/**"
- "packages/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "package.json"
- ".woodpecker/orchestrator.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Quality Gates ===
install:
image: *node_image
commands:
- *install_deps
security-audit:
image: *node_image
commands:
- *use_deps
- pnpm audit --audit-level=high
depends_on:
- install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/orchestrator" lint
depends_on:
- install
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/orchestrator" typecheck
depends_on:
- install
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/orchestrator" test
depends_on:
- install
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/orchestrator
depends_on:
- lint
- typecheck
- test
- security-audit
# === Docker Build & Push ===
docker-build-orchestrator:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
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
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile $DESTINATIONS
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- build
# === Container Security Scan ===
security-trivy-orchestrator:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- docker-build-orchestrator
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-orchestrator"
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- security-trivy-orchestrator

204
.woodpecker/web.yml Normal file
View File

@@ -0,0 +1,204 @@
# Web Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for @mosaic/web
#
# Triggers on: apps/web/**, packages/**, root configs
# Security chain: source audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/web/**"
- "packages/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "package.json"
- ".woodpecker/web.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Quality Gates ===
install:
image: *node_image
commands:
- *install_deps
security-audit:
image: *node_image
commands:
- *use_deps
- pnpm audit --audit-level=high
depends_on:
- 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:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/web" lint
depends_on:
- build-shared
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/web" typecheck
depends_on:
- build-shared
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/web" test
depends_on:
- build-shared
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/web
depends_on:
- lint
- typecheck
- test
- security-audit
# === Docker Build & Push ===
docker-build-web:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
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
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- build
# === Container Security Scan ===
security-trivy-web:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- docker-build-web
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-web"
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- security-trivy-web

View File

@@ -3,7 +3,7 @@
## Load Order ## Load Order
1. `SOUL.md` (repo identity + behavior invariants) 1. `SOUL.md` (repo identity + behavior invariants)
2. `~/.config/mosaic/STANDARDS.md` (machine-wide standards rails) 2. `~/.mosaic/STANDARDS.md` (machine-wide standards rails)
3. `AGENTS.md` (repo-specific overlay) 3. `AGENTS.md` (repo-specific overlay)
4. `.mosaic/repo-hooks.sh` (repo lifecycle hooks) 4. `.mosaic/repo-hooks.sh` (repo lifecycle hooks)
@@ -11,7 +11,7 @@
- This file is authoritative for repo-local operations. - This file is authoritative for repo-local operations.
- `CLAUDE.md` is a compatibility pointer to `AGENTS.md`. - `CLAUDE.md` is a compatibility pointer to `AGENTS.md`.
- Follow universal rails from `~/.config/mosaic/guides/` and `~/.config/mosaic/rails/`. - Follow universal rails from `~/.mosaic/guides/` and `~/.mosaic/rails/`.
## Session Lifecycle ## Session Lifecycle
@@ -25,8 +25,6 @@ Optional:
```bash ```bash
bash scripts/agent/log-limitation.sh "Short Name" 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 ## Repo Context
@@ -46,21 +44,6 @@ pnpm lint
pnpm build 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 ## Standards and Quality
- Enforce strict typing and no unsafe shortcuts. - Enforce strict typing and no unsafe shortcuts.

View File

@@ -1,10 +1,14 @@
# CLAUDE Compatibility Pointer # Compatibility Pointer
This file exists so Claude Code sessions load Mosaic standards. This repository uses an agent-neutral Mosaic standards model.
## MANDATORY — Read Before Any Response Authoritative repo guidance is in `AGENTS.md`.
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`. Load order for Claude sessions:
That file is the universal agent configuration. Do NOT respond until you have loaded it. 1. `SOUL.md`
Then read the project-local `AGENTS.md` in this repository for project-specific guidance. 2. `~/.mosaic/STANDARDS.md`
3. `AGENTS.md`
4. `.mosaic/repo-hooks.sh`
If you were started from `CLAUDE.md`, continue by reading `AGENTS.md` now.

View File

@@ -232,7 +232,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
@@ -526,9 +526,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 +739,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

View File

@@ -10,7 +10,7 @@ You are Jarvis for the Mosaic Stack repository, running on the current agent run
- Be calm and clear: keep responses concise, chunked, and PDA-friendly. - Be calm and clear: keep responses concise, chunked, and PDA-friendly.
- Respect canonical sources: - Respect canonical sources:
- Repo operations and conventions: `AGENTS.md` - Repo operations and conventions: `AGENTS.md`
- Machine-wide rails: `~/.config/mosaic/STANDARDS.md` - Machine-wide rails: `~/.mosaic/STANDARDS.md`
- Repo lifecycle hooks: `.mosaic/repo-hooks.sh` - Repo lifecycle hooks: `.mosaic/repo-hooks.sh`
## Guardrails ## Guardrails

View File

@@ -1,7 +1,10 @@
# 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 # Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries. # (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base 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,27 +21,15 @@ COPY turbo.json ./
# ====================== # ======================
FROM base AS deps FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation)
# Note: openssl and ca-certificates pre-installed in base image
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& 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/
# Copy npm configuration for native binary architecture hints # Install dependencies with pnpm store cache
COPY .npmrc ./ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
# scripts or fail to find prebuilt binaries for this Node.js version
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
@@ -64,15 +55,17 @@ RUN pnpm turbo build --filter=@mosaic/api --force
# ====================== # ======================
# Production stage # Production stage
# ====================== # ======================
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production FROM node:24-slim AS production
# dumb-init, openssl, ca-certificates pre-installed in base image # Remove npm (unused in production — we use pnpm) to reduce attack surface
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot) # Install dumb-init for proper signal handling
# - Remove npm/npx to reduce image size (not used in production) RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
# - Create non-root user && rm -rf /var/lib/apt/lists/*
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs # Create non-root user
RUN 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",
@@ -36,7 +36,6 @@
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.1.12", "@nestjs/platform-express": "^11.1.12",
"@nestjs/platform-socket.io": "^11.1.12", "@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.12", "@nestjs/websockets": "^11.1.12",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
@@ -53,16 +52,13 @@
"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",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dockerode": "^4.0.9",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"jose": "^6.1.3", "jose": "^6.1.3",
@@ -70,7 +66,6 @@
"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", "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",
@@ -89,9 +84,7 @@
"@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/dockerode": "^3.3.47",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/highlight.js": "^10.1.0", "@types/highlight.js": "^10.1.0",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",

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

@@ -1,24 +0,0 @@
-- CreateTable
CREATE TABLE "agent_memories" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "agent_memories_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "agent_memories_workspace_id_agent_id_key_key" ON "agent_memories"("workspace_id", "agent_id", "key");
-- CreateIndex
CREATE INDEX "agent_memories_workspace_id_idx" ON "agent_memories"("workspace_id");
-- CreateIndex
CREATE INDEX "agent_memories_agent_id_idx" ON "agent_memories"("agent_id");
-- AddForeignKey
ALTER TABLE "agent_memories" ADD CONSTRAINT "agent_memories_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,33 +0,0 @@
-- CreateTable
CREATE TABLE "conversation_archives" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"session_id" TEXT NOT NULL,
"agent_id" TEXT NOT NULL,
"messages" JSONB NOT NULL,
"message_count" INTEGER NOT NULL,
"summary" TEXT NOT NULL,
"embedding" vector(1536),
"started_at" TIMESTAMPTZ NOT NULL,
"ended_at" TIMESTAMPTZ,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "conversation_archives_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "conversation_archives_workspace_id_session_id_key" ON "conversation_archives"("workspace_id", "session_id");
-- CreateIndex
CREATE INDEX "conversation_archives_workspace_id_idx" ON "conversation_archives"("workspace_id");
-- CreateIndex
CREATE INDEX "conversation_archives_agent_id_idx" ON "conversation_archives"("agent_id");
-- CreateIndex
CREATE INDEX "conversation_archives_started_at_idx" ON "conversation_archives"("started_at");
-- AddForeignKey
ALTER TABLE "conversation_archives" ADD CONSTRAINT "conversation_archives_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,109 +0,0 @@
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"encrypted" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BreakglassUser" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BreakglassUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LlmProvider" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"baseUrl" TEXT,
"apiKey" TEXT,
"apiType" TEXT NOT NULL DEFAULT 'openai-completions',
"models" JSONB NOT NULL DEFAULT '[]',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LlmProvider_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserContainer" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"containerId" TEXT,
"containerName" TEXT NOT NULL,
"gatewayPort" INTEGER,
"gatewayToken" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'stopped',
"lastActiveAt" TIMESTAMP(3),
"idleTimeoutMin" INTEGER NOT NULL DEFAULT 30,
"config" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserContainer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemContainer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL,
"containerId" TEXT,
"gatewayPort" INTEGER,
"gatewayToken" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'stopped',
"primaryModel" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemContainer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserAgentConfig" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"primaryModel" TEXT,
"fallbackModels" JSONB NOT NULL DEFAULT '[]',
"personality" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserAgentConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
-- CreateIndex
CREATE UNIQUE INDEX "BreakglassUser_username_key" ON "BreakglassUser"("username");
-- CreateIndex
CREATE INDEX "LlmProvider_userId_idx" ON "LlmProvider"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "LlmProvider_userId_name_key" ON "LlmProvider"("userId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "UserContainer_userId_key" ON "UserContainer"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SystemContainer_name_key" ON "SystemContainer"("name");
-- CreateIndex
CREATE UNIQUE INDEX "UserAgentConfig_userId_key" ON "UserAgentConfig"("userId");

View File

@@ -1,37 +0,0 @@
-- CreateTable
CREATE TABLE "findings" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"task_id" UUID,
"agent_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"data" JSONB NOT NULL,
"summary" TEXT NOT NULL,
"embedding" vector(1536),
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "findings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "findings_id_workspace_id_key" ON "findings"("id", "workspace_id");
-- CreateIndex
CREATE INDEX "findings_workspace_id_idx" ON "findings"("workspace_id");
-- CreateIndex
CREATE INDEX "findings_agent_id_idx" ON "findings"("agent_id");
-- CreateIndex
CREATE INDEX "findings_type_idx" ON "findings"("type");
-- CreateIndex
CREATE INDEX "findings_task_id_idx" ON "findings"("task_id");
-- AddForeignKey
ALTER TABLE "findings" ADD CONSTRAINT "findings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "findings" ADD CONSTRAINT "findings_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "agent_tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "assigned_agent" TEXT;

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[]
@@ -298,8 +284,6 @@ model Workspace {
agents Agent[] agents Agent[]
agentSessions AgentSession[] agentSessions AgentSession[]
agentTasks AgentTask[] agentTasks AgentTask[]
findings Finding[]
agentMemories AgentMemory[]
userLayouts UserLayout[] userLayouts UserLayout[]
knowledgeEntries KnowledgeEntry[] knowledgeEntries KnowledgeEntry[]
knowledgeTags KnowledgeTag[] knowledgeTags KnowledgeTag[]
@@ -313,8 +297,6 @@ model Workspace {
federationEventSubscriptions FederationEventSubscription[] federationEventSubscriptions FederationEventSubscription[]
llmUsageLogs LlmUsageLog[] llmUsageLogs LlmUsageLog[]
userCredentials UserCredential[] userCredentials UserCredential[]
terminalSessions TerminalSession[]
conversationArchives ConversationArchive[]
@@index([ownerId]) @@index([ownerId])
@@map("workspaces") @@map("workspaces")
@@ -379,7 +361,6 @@ model Task {
creatorId String @map("creator_id") @db.Uuid creatorId String @map("creator_id") @db.Uuid
projectId String? @map("project_id") @db.Uuid projectId String? @map("project_id") @db.Uuid
parentId String? @map("parent_id") @db.Uuid parentId String? @map("parent_id") @db.Uuid
assignedAgent String? @map("assigned_agent")
domainId String? @map("domain_id") @db.Uuid domainId String? @map("domain_id") @db.Uuid
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
metadata Json @default("{}") metadata Json @default("{}")
@@ -693,7 +674,6 @@ model AgentTask {
createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade) createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade)
createdById String @map("created_by_id") @db.Uuid createdById String @map("created_by_id") @db.Uuid
runnerJobs RunnerJob[] runnerJobs RunnerJob[]
findings Finding[]
@@unique([id, workspaceId]) @@unique([id, workspaceId])
@@index([workspaceId]) @@index([workspaceId])
@@ -703,33 +683,6 @@ model AgentTask {
@@map("agent_tasks") @@map("agent_tasks")
} }
model Finding {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
taskId String? @map("task_id") @db.Uuid
agentId String @map("agent_id")
type String
title String
data Json
summary String @db.Text
// Note: vector dimension (1536) must match EMBEDDING_DIMENSION constant in @mosaic/shared
embedding Unsupported("vector(1536)")?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
task AgentTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
@@unique([id, workspaceId])
@@index([workspaceId])
@@index([agentId])
@@index([type])
@@index([taskId])
@@map("findings")
}
model AgentSession { model AgentSession {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid workspaceId String @map("workspace_id") @db.Uuid
@@ -767,23 +720,6 @@ model AgentSession {
@@map("agent_sessions") @@map("agent_sessions")
} }
model AgentMemory {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
agentId String @map("agent_id")
key String
value Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, agentId, key])
@@index([workspaceId])
@@index([agentId])
@@map("agent_memories")
}
model WidgetDefinition { model WidgetDefinition {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
@@ -1125,10 +1061,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
@@ -1575,131 +1507,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")
}
// ============================================
// CONVERSATION ARCHIVE MODULE
// ============================================
model ConversationArchive {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
sessionId String @map("session_id")
agentId String @map("agent_id")
messages Json
messageCount Int @map("message_count")
summary String @db.Text
// Note: vector dimension (1536) must match EMBEDDING_DIMENSION constant in @mosaic/shared
embedding Unsupported("vector(1536)")?
startedAt DateTime @map("started_at") @db.Timestamptz
endedAt DateTime? @map("ended_at") @db.Timestamptz
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, sessionId])
@@index([workspaceId])
@@index([agentId])
@@index([startedAt])
@@map("conversation_archives")
}
// ============================================
// AGENT FLEET MODULE
// ============================================
model SystemConfig {
id String @id @default(cuid())
key String @unique
value String
encrypted Boolean @default(false)
updatedAt DateTime @updatedAt
}
model BreakglassUser {
id String @id @default(cuid())
username String @unique
passwordHash String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LlmProvider {
id String @id @default(cuid())
userId String
name String
displayName String
type String
baseUrl String?
apiKey String?
apiType String @default("openai-completions")
models Json @default("[]")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
model UserContainer {
id String @id @default(cuid())
userId String @unique
containerId String?
containerName String
gatewayPort Int?
gatewayToken String
status String @default("stopped")
lastActiveAt DateTime?
idleTimeoutMin Int @default(30)
config Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemContainer {
id String @id @default(cuid())
name String @unique
role String
containerId String?
gatewayPort Int?
gatewayToken String
status String @default("stopped")
primaryModel String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserAgentConfig {
id String @id @default(cuid())
userId String @unique
primaryModel String?
fallbackModels Json @default("[]")
personality String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

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,7 +1,7 @@
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common"; import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
import { ActivityService } from "./activity.service"; import { ActivityService } from "./activity.service";
import { EntityType } from "@prisma/client"; import { EntityType } from "@prisma/client";
import { QueryActivityLogDto } from "./dto"; import type { QueryActivityLogDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators"; import { Workspace, Permission, RequirePermission } from "../common/decorators";

View File

@@ -117,13 +117,12 @@ export class ActivityService {
/** /**
* Get a single activity log by ID * Get a single activity log by ID
*/ */
async findOne(id: string, workspaceId?: string): Promise<ActivityLogResult | null> { async findOne(id: string, workspaceId: string): Promise<ActivityLogResult | null> {
const where: Prisma.ActivityLogWhereUniqueInput = { id };
if (workspaceId) {
where.workspaceId = workspaceId;
}
return await this.prisma.activityLog.findUnique({ return await this.prisma.activityLog.findUnique({
where, where: {
id,
workspaceId,
},
include: { include: {
user: { user: {
select: { select: {

View File

@@ -384,18 +384,10 @@ describe("ActivityLoggingInterceptor", () => {
const context = createMockExecutionContext("POST", {}, body, user); const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result); const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => { interceptor.intercept(context, next).subscribe(() => {
// workspaceId is now optional, so logActivity should be called without it // Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).toHaveBeenCalled(); expect(mockActivityService.logActivity).not.toHaveBeenCalled();
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
expect(callArgs.userId).toBe("user-123");
expect(callArgs.entityId).toBe("task-123");
expect(callArgs.workspaceId).toBeUndefined();
resolve(); resolve();
}); });
}); });
@@ -420,18 +412,10 @@ describe("ActivityLoggingInterceptor", () => {
const context = createMockExecutionContext("POST", {}, body, user); const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result); const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => { interceptor.intercept(context, next).subscribe(() => {
// workspaceId is now optional, so logActivity should be called without it // Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).toHaveBeenCalled(); expect(mockActivityService.logActivity).not.toHaveBeenCalled();
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
expect(callArgs.userId).toBe("user-123");
expect(callArgs.entityId).toBe("task-123");
expect(callArgs.workspaceId).toBeUndefined();
resolve(); resolve();
}); });
}); });

View File

@@ -4,7 +4,6 @@ import { tap } from "rxjs/operators";
import { ActivityService } from "../activity.service"; import { ActivityService } from "../activity.service";
import { ActivityAction, EntityType } from "@prisma/client"; import { ActivityAction, EntityType } from "@prisma/client";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import type { CreateActivityLogInput } from "../interfaces/activity.interface";
import type { AuthenticatedRequest } from "../../common/types/user.types"; import type { AuthenticatedRequest } from "../../common/types/user.types";
/** /**
@@ -62,13 +61,10 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
// Extract entity information // Extract entity information
const resultObj = result as Record<string, unknown> | undefined; const resultObj = result as Record<string, unknown> | undefined;
const entityId = params.id ?? (resultObj?.id as string | undefined); const entityId = params.id ?? (resultObj?.id as string | undefined);
// workspaceId is now optional - log events even when missing
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined); const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
// Log with warning if entityId is missing, but still proceed with logging if workspaceId exists if (!entityId || !workspaceId) {
if (!entityId) { this.logger.warn("Cannot log activity: missing entityId or workspaceId");
this.logger.warn("Cannot log activity: missing entityId");
return; return;
} }
@@ -96,8 +92,9 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
const userAgent = const userAgent =
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0]; typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
// Log the activity — workspaceId is optional // Log the activity
const activityInput: CreateActivityLogInput = { await this.activityService.logActivity({
workspaceId,
userId: user.id, userId: user.id,
action, action,
entityType, entityType,
@@ -105,11 +102,7 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
details, details,
ipAddress: ip ?? undefined, ipAddress: ip ?? undefined,
userAgent: userAgent ?? undefined, userAgent: userAgent ?? undefined,
}; });
if (workspaceId) {
activityInput.workspaceId = workspaceId;
}
await this.activityService.logActivity(activityInput);
} catch (error) { } catch (error) {
// Don't fail the request if activity logging fails // Don't fail the request if activity logging fails
this.logger.error( this.logger.error(

View File

@@ -2,10 +2,9 @@ import type { ActivityAction, EntityType, Prisma } from "@prisma/client";
/** /**
* Interface for creating a new activity log entry * Interface for creating a new activity log entry
* workspaceId is optional - allows logging events without workspace context
*/ */
export interface CreateActivityLogInput { export interface CreateActivityLogInput {
workspaceId?: string | null; workspaceId: string;
userId: string; userId: string;
action: ActivityAction; action: ActivityAction;
entityType: EntityType; entityType: EntityType;

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,40 +0,0 @@
import {
Controller,
ForbiddenException,
Get,
Param,
Req,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { AgentConfigService } from "./agent-config.service";
import { AgentConfigGuard, type AgentConfigRequest } from "./agent-config.guard";
@Controller("internal")
@UseGuards(AgentConfigGuard)
export class AgentConfigController {
constructor(private readonly agentConfigService: AgentConfigService) {}
// GET /api/internal/agent-config/:id
// Auth: Bearer token (validated against UserContainer.gatewayToken or SystemContainer.gatewayToken)
// Returns: assembled openclaw.json
//
// The :id param is the container record ID (cuid)
// Token must match the container requesting its own config
@Get("agent-config/:id")
async getAgentConfig(
@Param("id") id: string,
@Req() request: AgentConfigRequest
): Promise<object> {
const containerAuth = request.containerAuth;
if (!containerAuth) {
throw new UnauthorizedException("Missing container authentication context");
}
if (containerAuth.id !== id) {
throw new ForbiddenException("Token is not authorized for the requested container");
}
return this.agentConfigService.generateConfigForContainer(containerAuth.type, id);
}
}

View File

@@ -1,43 +0,0 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import type { Request } from "express";
import { AgentConfigService, type ContainerTokenValidation } from "./agent-config.service";
export interface AgentConfigRequest extends Request {
containerAuth?: ContainerTokenValidation;
}
@Injectable()
export class AgentConfigGuard implements CanActivate {
constructor(private readonly agentConfigService: AgentConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AgentConfigRequest>();
const token = this.extractBearerToken(request.headers.authorization);
if (!token) {
throw new UnauthorizedException("Missing Bearer token");
}
const containerAuth = await this.agentConfigService.validateContainerToken(token);
if (!containerAuth) {
throw new UnauthorizedException("Invalid container token");
}
request.containerAuth = containerAuth;
return true;
}
private extractBearerToken(headerValue: string | string[] | undefined): string | null {
const normalizedHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue;
if (!normalizedHeader) {
return null;
}
const [scheme, token] = normalizedHeader.split(" ");
if (!scheme || !token || scheme.toLowerCase() !== "bearer") {
return null;
}
return token;
}
}

View File

@@ -1,14 +0,0 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { AgentConfigController } from "./agent-config.controller";
import { AgentConfigService } from "./agent-config.service";
import { AgentConfigGuard } from "./agent-config.guard";
@Module({
imports: [PrismaModule, CryptoModule],
controllers: [AgentConfigController],
providers: [AgentConfigService, AgentConfigGuard],
exports: [AgentConfigService],
})
export class AgentConfigModule {}

View File

@@ -1,215 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AgentConfigService } from "./agent-config.service";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
describe("AgentConfigService", () => {
let service: AgentConfigService;
const mockPrismaService = {
userAgentConfig: {
findUnique: vi.fn(),
},
llmProvider: {
findMany: vi.fn(),
},
userContainer: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
systemContainer: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
};
const mockCryptoService = {
isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
};
beforeEach(() => {
vi.clearAllMocks();
service = new AgentConfigService(
mockPrismaService as unknown as PrismaService,
mockCryptoService as unknown as CryptoService
);
});
it("generateUserConfig returns valid openclaw.json structure", async () => {
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
id: "cfg-1",
userId: "user-1",
primaryModel: "my-zai/glm-5",
});
mockPrismaService.userContainer.findUnique.mockResolvedValue({
id: "container-1",
userId: "user-1",
gatewayPort: 19001,
});
mockPrismaService.llmProvider.findMany.mockResolvedValue([
{
id: "provider-1",
userId: "user-1",
name: "my-zai",
displayName: "Z.ai",
type: "zai",
baseUrl: "https://api.z.ai/v1",
apiKey: "enc:secret-zai-key",
apiType: "openai-completions",
models: [{ id: "glm-5" }],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
const result = await service.generateUserConfig("user-1");
expect(result).toEqual({
gateway: {
mode: "local",
port: 19001,
bind: "lan",
auth: { mode: "token" },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: {
defaults: {
model: {
primary: "my-zai/glm-5",
},
},
},
models: {
providers: {
"my-zai": {
apiKey: "secret-zai-key",
baseUrl: "https://api.z.ai/v1",
models: {
"glm-5": {},
},
},
},
},
});
});
it("generateUserConfig decrypts API keys correctly", async () => {
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
id: "cfg-1",
userId: "user-1",
primaryModel: "openai-work/gpt-4.1",
});
mockPrismaService.userContainer.findUnique.mockResolvedValue({
id: "container-1",
userId: "user-1",
gatewayPort: 18789,
});
mockPrismaService.llmProvider.findMany.mockResolvedValue([
{
id: "provider-1",
userId: "user-1",
name: "openai-work",
displayName: "OpenAI Work",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "enc:encrypted-openai-key",
apiType: "openai-completions",
models: [{ id: "gpt-4.1" }],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
const result = await service.generateUserConfig("user-1");
expect(mockCryptoService.decrypt).toHaveBeenCalledWith("enc:encrypted-openai-key");
expect(result.models.providers["openai-work"]?.apiKey).toBe("encrypted-openai-key");
});
it("generateUserConfig handles user with no providers", async () => {
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
id: "cfg-1",
userId: "user-2",
primaryModel: "openai/gpt-4o-mini",
});
mockPrismaService.userContainer.findUnique.mockResolvedValue({
id: "container-2",
userId: "user-2",
gatewayPort: null,
});
mockPrismaService.llmProvider.findMany.mockResolvedValue([]);
const result = await service.generateUserConfig("user-2");
expect(result.models.providers).toEqual({});
expect(result.gateway.port).toBe(18789);
});
it("validateContainerToken returns correct type for user container", async () => {
mockPrismaService.userContainer.findMany.mockResolvedValue([
{
id: "user-container-1",
gatewayToken: "enc:user-token-1",
},
]);
mockPrismaService.systemContainer.findMany.mockResolvedValue([]);
const result = await service.validateContainerToken("user-token-1");
expect(result).toEqual({
type: "user",
id: "user-container-1",
});
});
it("validateContainerToken returns correct type for system container", async () => {
mockPrismaService.userContainer.findMany.mockResolvedValue([]);
mockPrismaService.systemContainer.findMany.mockResolvedValue([
{
id: "system-container-1",
gatewayToken: "enc:system-token-1",
},
]);
const result = await service.validateContainerToken("system-token-1");
expect(result).toEqual({
type: "system",
id: "system-container-1",
});
});
it("validateContainerToken returns null for invalid token", async () => {
mockPrismaService.userContainer.findMany.mockResolvedValue([
{
id: "user-container-1",
gatewayToken: "enc:user-token-1",
},
]);
mockPrismaService.systemContainer.findMany.mockResolvedValue([
{
id: "system-container-1",
gatewayToken: "enc:system-token-1",
},
]);
const result = await service.validateContainerToken("no-match");
expect(result).toBeNull();
});
});

View File

@@ -1,285 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import type { LlmProvider } from "@prisma/client";
import { createHash, timingSafeEqual } from "node:crypto";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
const DEFAULT_GATEWAY_PORT = 18789;
const DEFAULT_PRIMARY_MODEL = "openai/gpt-4o-mini";
type ContainerType = "user" | "system";
export interface ContainerTokenValidation {
type: ContainerType;
id: string;
}
type OpenClawModelMap = Record<string, Record<string, never>>;
interface OpenClawProviderConfig {
apiKey?: string;
baseUrl?: string;
models: OpenClawModelMap;
}
interface OpenClawConfig {
gateway: {
mode: "local";
port: number;
bind: "lan";
auth: { mode: "token" };
http: {
endpoints: {
chatCompletions: { enabled: true };
};
};
};
agents: {
defaults: {
model: {
primary: string;
};
};
};
models: {
providers: Record<string, OpenClawProviderConfig>;
};
}
@Injectable()
export class AgentConfigService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService
) {}
// Generate complete openclaw.json for a user container
async generateUserConfig(userId: string): Promise<OpenClawConfig> {
const [userAgentConfig, providers, userContainer] = await Promise.all([
this.prisma.userAgentConfig.findUnique({
where: { userId },
}),
this.prisma.llmProvider.findMany({
where: {
userId,
isActive: true,
},
orderBy: {
createdAt: "asc",
},
}),
this.prisma.userContainer.findUnique({
where: { userId },
}),
]);
if (!userContainer) {
throw new NotFoundException(`User container not found for user ${userId}`);
}
const primaryModel =
userAgentConfig?.primaryModel ??
this.resolvePrimaryModelFromProviders(providers) ??
DEFAULT_PRIMARY_MODEL;
return this.buildOpenClawConfig(primaryModel, userContainer.gatewayPort, providers);
}
// Generate config for a system container
async generateSystemConfig(containerId: string): Promise<OpenClawConfig> {
const systemContainer = await this.prisma.systemContainer.findUnique({
where: { id: containerId },
});
if (!systemContainer) {
throw new NotFoundException(`System container ${containerId} not found`);
}
return this.buildOpenClawConfig(
systemContainer.primaryModel || DEFAULT_PRIMARY_MODEL,
systemContainer.gatewayPort,
[]
);
}
async generateConfigForContainer(
type: ContainerType,
containerId: string
): Promise<OpenClawConfig> {
if (type === "system") {
return this.generateSystemConfig(containerId);
}
const userContainer = await this.prisma.userContainer.findUnique({
where: { id: containerId },
select: { userId: true },
});
if (!userContainer) {
throw new NotFoundException(`User container ${containerId} not found`);
}
return this.generateUserConfig(userContainer.userId);
}
// Validate a container's bearer token
async validateContainerToken(token: string): Promise<ContainerTokenValidation | null> {
if (!token) {
return null;
}
const [userContainers, systemContainers] = await Promise.all([
this.prisma.userContainer.findMany({
select: {
id: true,
gatewayToken: true,
},
}),
this.prisma.systemContainer.findMany({
select: {
id: true,
gatewayToken: true,
},
}),
]);
let match: ContainerTokenValidation | null = null;
for (const container of userContainers) {
const storedToken = this.decryptContainerToken(container.gatewayToken);
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
match = { type: "user", id: container.id };
}
}
for (const container of systemContainers) {
const storedToken = this.decryptContainerToken(container.gatewayToken);
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
match = { type: "system", id: container.id };
}
}
return match;
}
private buildOpenClawConfig(
primaryModel: string,
gatewayPort: number | null,
providers: LlmProvider[]
): OpenClawConfig {
return {
gateway: {
mode: "local",
port: gatewayPort ?? DEFAULT_GATEWAY_PORT,
bind: "lan",
auth: { mode: "token" },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: {
defaults: {
model: {
primary: primaryModel,
},
},
},
models: {
providers: this.buildProviderConfig(providers),
},
};
}
private buildProviderConfig(providers: LlmProvider[]): Record<string, OpenClawProviderConfig> {
const providerConfig: Record<string, OpenClawProviderConfig> = {};
for (const provider of providers) {
const config: OpenClawProviderConfig = {
models: this.extractModels(provider.models),
};
const apiKey = this.decryptIfNeeded(provider.apiKey);
if (apiKey) {
config.apiKey = apiKey;
}
if (provider.baseUrl) {
config.baseUrl = provider.baseUrl;
}
providerConfig[provider.name] = config;
}
return providerConfig;
}
private extractModels(models: unknown): OpenClawModelMap {
const modelMap: OpenClawModelMap = {};
if (!Array.isArray(models)) {
return modelMap;
}
for (const modelEntry of models) {
if (typeof modelEntry === "string") {
modelMap[modelEntry] = {};
continue;
}
if (this.hasModelId(modelEntry)) {
modelMap[modelEntry.id] = {};
}
}
return modelMap;
}
private resolvePrimaryModelFromProviders(providers: LlmProvider[]): string | null {
for (const provider of providers) {
const modelIds = Object.keys(this.extractModels(provider.models));
const firstModelId = modelIds[0];
if (firstModelId) {
return `${provider.name}/${firstModelId}`;
}
}
return null;
}
private decryptIfNeeded(value: string | null | undefined): string | undefined {
if (!value) {
return undefined;
}
if (this.crypto.isEncrypted(value)) {
return this.crypto.decrypt(value);
}
return value;
}
private decryptContainerToken(value: string): string | null {
try {
return this.decryptIfNeeded(value) ?? null;
} catch {
return null;
}
}
private tokensEqual(left: string, right: string): boolean {
const leftDigest = createHash("sha256").update(left, "utf8").digest();
const rightDigest = createHash("sha256").update(right, "utf8").digest();
return timingSafeEqual(leftDigest, rightDigest);
}
private hasModelId(modelEntry: unknown): modelEntry is { id: string } {
if (typeof modelEntry !== "object" || modelEntry === null || !("id" in modelEntry)) {
return false;
}
return typeof (modelEntry as { id?: unknown }).id === "string";
}
}

View File

@@ -1,102 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { AgentMemoryController } from "./agent-memory.controller";
import { AgentMemoryService } from "./agent-memory.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { describe, it, expect, beforeEach, vi } from "vitest";
describe("AgentMemoryController", () => {
let controller: AgentMemoryController;
const mockAgentMemoryService = {
upsert: vi.fn(),
findAll: vi.fn(),
findOne: vi.fn(),
remove: vi.fn(),
};
const mockGuard = { canActivate: vi.fn(() => true) };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AgentMemoryController],
providers: [
{
provide: AgentMemoryService,
useValue: mockAgentMemoryService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockGuard)
.overrideGuard(WorkspaceGuard)
.useValue(mockGuard)
.overrideGuard(PermissionGuard)
.useValue(mockGuard)
.compile();
controller = module.get<AgentMemoryController>(AgentMemoryController);
vi.clearAllMocks();
});
const workspaceId = "workspace-1";
const agentId = "agent-1";
const key = "context";
describe("upsert", () => {
it("should upsert a memory entry", async () => {
const dto = { value: { foo: "bar" } };
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: dto.value };
mockAgentMemoryService.upsert.mockResolvedValue(mockEntry);
const result = await controller.upsert(agentId, key, dto, workspaceId);
expect(mockAgentMemoryService.upsert).toHaveBeenCalledWith(workspaceId, agentId, key, dto);
expect(result).toEqual(mockEntry);
});
});
describe("findAll", () => {
it("should list all memory entries for an agent", async () => {
const mockEntries = [
{ id: "mem-1", key: "a", value: 1 },
{ id: "mem-2", key: "b", value: 2 },
];
mockAgentMemoryService.findAll.mockResolvedValue(mockEntries);
const result = await controller.findAll(agentId, workspaceId);
expect(mockAgentMemoryService.findAll).toHaveBeenCalledWith(workspaceId, agentId);
expect(result).toEqual(mockEntries);
});
});
describe("findOne", () => {
it("should get a single memory entry", async () => {
const mockEntry = { id: "mem-1", key, value: "v" };
mockAgentMemoryService.findOne.mockResolvedValue(mockEntry);
const result = await controller.findOne(agentId, key, workspaceId);
expect(mockAgentMemoryService.findOne).toHaveBeenCalledWith(workspaceId, agentId, key);
expect(result).toEqual(mockEntry);
});
});
describe("remove", () => {
it("should delete a memory entry", async () => {
const mockResponse = { message: "Memory entry deleted successfully" };
mockAgentMemoryService.remove.mockResolvedValue(mockResponse);
const result = await controller.remove(agentId, key, workspaceId);
expect(mockAgentMemoryService.remove).toHaveBeenCalledWith(workspaceId, agentId, key);
expect(result).toEqual(mockResponse);
});
});
});

View File

@@ -1,89 +0,0 @@
import {
Controller,
Get,
Put,
Delete,
Body,
Param,
UseGuards,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { AgentMemoryService } from "./agent-memory.service";
import { UpsertAgentMemoryDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
/**
* Controller for per-agent key/value memory endpoints.
* All endpoints require authentication and workspace context.
*
* Guards are applied in order:
* 1. AuthGuard - Verifies user authentication
* 2. WorkspaceGuard - Validates workspace access
* 3. PermissionGuard - Checks role-based permissions
*/
@Controller("agents/:agentId/memory")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class AgentMemoryController {
constructor(private readonly agentMemoryService: AgentMemoryService) {}
/**
* PUT /api/agents/:agentId/memory/:key
* Upsert a memory entry for an agent
* Requires: MEMBER role or higher
*/
@Put(":key")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async upsert(
@Param("agentId") agentId: string,
@Param("key") key: string,
@Body() dto: UpsertAgentMemoryDto,
@Workspace() workspaceId: string
) {
return this.agentMemoryService.upsert(workspaceId, agentId, key, dto);
}
/**
* GET /api/agents/:agentId/memory
* List all memory entries for an agent
* Requires: Any workspace member (including GUEST)
*/
@Get()
@RequirePermission(Permission.WORKSPACE_ANY)
async findAll(@Param("agentId") agentId: string, @Workspace() workspaceId: string) {
return this.agentMemoryService.findAll(workspaceId, agentId);
}
/**
* GET /api/agents/:agentId/memory/:key
* Get a single memory entry by key
* Requires: Any workspace member (including GUEST)
*/
@Get(":key")
@RequirePermission(Permission.WORKSPACE_ANY)
async findOne(
@Param("agentId") agentId: string,
@Param("key") key: string,
@Workspace() workspaceId: string
) {
return this.agentMemoryService.findOne(workspaceId, agentId, key);
}
/**
* DELETE /api/agents/:agentId/memory/:key
* Remove a memory entry
* Requires: MEMBER role or higher
*/
@Delete(":key")
@HttpCode(HttpStatus.OK)
@RequirePermission(Permission.WORKSPACE_MEMBER)
async remove(
@Param("agentId") agentId: string,
@Param("key") key: string,
@Workspace() workspaceId: string
) {
return this.agentMemoryService.remove(workspaceId, agentId, key);
}
}

View File

@@ -1,198 +0,0 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
import { AgentMemoryService } from "./agent-memory.service";
import { PrismaService } from "../prisma/prisma.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
async function createWorkspace(
prisma: PrismaClient,
label: string
): Promise<{ workspaceId: string; ownerId: string }> {
const workspace = await prisma.workspace.create({
data: {
name: `${label} ${Date.now()}`,
owner: {
create: {
email: `${label.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}@example.com`,
name: `${label} Owner`,
},
},
},
});
return {
workspaceId: workspace.id,
ownerId: workspace.ownerId,
};
}
describeFn("AgentMemoryService Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: AgentMemoryService;
let setupComplete = false;
let workspaceAId: string;
let workspaceAOwnerId: string;
let workspaceBId: string;
let workspaceBOwnerId: string;
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspaceA = await createWorkspace(prisma, "Agent Memory Integration A");
workspaceAId = workspaceA.workspaceId;
workspaceAOwnerId = workspaceA.ownerId;
const workspaceB = await createWorkspace(prisma, "Agent Memory Integration B");
workspaceBId = workspaceB.workspaceId;
workspaceBOwnerId = workspaceB.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
AgentMemoryService,
{
provide: PrismaService,
useValue: prisma,
},
],
}).compile();
service = moduleRef.get<AgentMemoryService>(AgentMemoryService);
setupComplete = true;
});
beforeEach(async () => {
if (!setupComplete) {
return;
}
await prisma.agentMemory.deleteMany({
where: {
workspaceId: {
in: [workspaceAId, workspaceBId],
},
},
});
});
afterAll(async () => {
if (!prisma) {
return;
}
const workspaceIds = [workspaceAId, workspaceBId].filter(
(id): id is string => typeof id === "string"
);
const ownerIds = [workspaceAOwnerId, workspaceBOwnerId].filter(
(id): id is string => typeof id === "string"
);
if (workspaceIds.length > 0) {
await prisma.agentMemory.deleteMany({
where: {
workspaceId: {
in: workspaceIds,
},
},
});
await prisma.workspace.deleteMany({ where: { id: { in: workspaceIds } } });
}
if (ownerIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: ownerIds } } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("upserts and lists memory entries", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
const entry = await service.upsert(workspaceAId, agentId, "session-context", {
value: { intent: "create-tests", depth: "integration" },
});
expect(entry.workspaceId).toBe(workspaceAId);
expect(entry.agentId).toBe(agentId);
expect(entry.key).toBe("session-context");
const listed = await service.findAll(workspaceAId, agentId);
expect(listed).toHaveLength(1);
expect(listed[0]?.id).toBe(entry.id);
expect(listed[0]?.value).toMatchObject({ intent: "create-tests" });
});
it("updates existing key via upsert without creating duplicates", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
const first = await service.upsert(workspaceAId, agentId, "preferences", {
value: { model: "fast" },
});
const second = await service.upsert(workspaceAId, agentId, "preferences", {
value: { model: "accurate" },
});
expect(second.id).toBe(first.id);
expect(second.value).toMatchObject({ model: "accurate" });
const rowCount = await prisma.agentMemory.count({
where: {
workspaceId: workspaceAId,
agentId,
key: "preferences",
},
});
expect(rowCount).toBe(1);
});
it("lists keys in sorted order and isolates by workspace", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
await service.upsert(workspaceAId, agentId, "beta", { value: { v: 2 } });
await service.upsert(workspaceAId, agentId, "alpha", { value: { v: 1 } });
await service.upsert(workspaceBId, agentId, "alpha", { value: { v: 99 } });
const workspaceAEntries = await service.findAll(workspaceAId, agentId);
const workspaceBEntries = await service.findAll(workspaceBId, agentId);
expect(workspaceAEntries.map((row) => row.key)).toEqual(["alpha", "beta"]);
expect(workspaceBEntries).toHaveLength(1);
expect(workspaceBEntries[0]?.value).toMatchObject({ v: 99 });
});
it("throws NotFoundException when requesting unknown key", async () => {
if (!setupComplete) {
return;
}
await expect(service.findOne(workspaceAId, `agent-${uuid()}`, "missing")).rejects.toThrow(
NotFoundException
);
});
});

View File

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

View File

@@ -1,126 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { AgentMemoryService } from "./agent-memory.service";
import { PrismaService } from "../prisma/prisma.service";
import { NotFoundException } from "@nestjs/common";
import { describe, it, expect, beforeEach, vi } from "vitest";
describe("AgentMemoryService", () => {
let service: AgentMemoryService;
const mockPrismaService = {
agentMemory: {
upsert: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentMemoryService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<AgentMemoryService>(AgentMemoryService);
vi.clearAllMocks();
});
const workspaceId = "workspace-1";
const agentId = "agent-1";
const key = "session-context";
describe("upsert", () => {
it("should upsert a memory entry", async () => {
const dto = { value: { data: "some context" } };
const mockEntry = {
id: "mem-1",
workspaceId,
agentId,
key,
value: dto.value,
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrismaService.agentMemory.upsert.mockResolvedValue(mockEntry);
const result = await service.upsert(workspaceId, agentId, key, dto);
expect(mockPrismaService.agentMemory.upsert).toHaveBeenCalledWith({
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
create: { workspaceId, agentId, key, value: dto.value },
update: { value: dto.value },
});
expect(result).toEqual(mockEntry);
});
});
describe("findAll", () => {
it("should return all memory entries for an agent", async () => {
const mockEntries = [
{ id: "mem-1", key: "a", value: 1 },
{ id: "mem-2", key: "b", value: 2 },
];
mockPrismaService.agentMemory.findMany.mockResolvedValue(mockEntries);
const result = await service.findAll(workspaceId, agentId);
expect(mockPrismaService.agentMemory.findMany).toHaveBeenCalledWith({
where: { workspaceId, agentId },
orderBy: { key: "asc" },
});
expect(result).toEqual(mockEntries);
});
});
describe("findOne", () => {
it("should return a memory entry by key", async () => {
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: "ctx" };
mockPrismaService.agentMemory.findUnique.mockResolvedValue(mockEntry);
const result = await service.findOne(workspaceId, agentId, key);
expect(mockPrismaService.agentMemory.findUnique).toHaveBeenCalledWith({
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
});
expect(result).toEqual(mockEntry);
});
it("should throw NotFoundException when key not found", async () => {
mockPrismaService.agentMemory.findUnique.mockResolvedValue(null);
await expect(service.findOne(workspaceId, agentId, key)).rejects.toThrow(NotFoundException);
});
});
describe("remove", () => {
it("should delete a memory entry", async () => {
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: "x" };
mockPrismaService.agentMemory.findUnique.mockResolvedValue(mockEntry);
mockPrismaService.agentMemory.delete.mockResolvedValue(mockEntry);
const result = await service.remove(workspaceId, agentId, key);
expect(mockPrismaService.agentMemory.delete).toHaveBeenCalledWith({
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
});
expect(result).toEqual({ message: "Memory entry deleted successfully" });
});
it("should throw NotFoundException when key not found", async () => {
mockPrismaService.agentMemory.findUnique.mockResolvedValue(null);
await expect(service.remove(workspaceId, agentId, key)).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -1,79 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { Prisma } from "@prisma/client";
import type { UpsertAgentMemoryDto } from "./dto";
@Injectable()
export class AgentMemoryService {
constructor(private readonly prisma: PrismaService) {}
/**
* Upsert a memory entry for an agent.
*/
async upsert(workspaceId: string, agentId: string, key: string, dto: UpsertAgentMemoryDto) {
return this.prisma.agentMemory.upsert({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
create: {
workspaceId,
agentId,
key,
value: dto.value as Prisma.InputJsonValue,
},
update: {
value: dto.value as Prisma.InputJsonValue,
},
});
}
/**
* List all memory entries for an agent in a workspace.
*/
async findAll(workspaceId: string, agentId: string) {
return this.prisma.agentMemory.findMany({
where: { workspaceId, agentId },
orderBy: { key: "asc" },
});
}
/**
* Get a single memory entry by key.
*/
async findOne(workspaceId: string, agentId: string, key: string) {
const entry = await this.prisma.agentMemory.findUnique({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
});
if (!entry) {
throw new NotFoundException(`Memory key "${key}" not found for agent "${agentId}"`);
}
return entry;
}
/**
* Delete a memory entry by key.
*/
async remove(workspaceId: string, agentId: string, key: string) {
const entry = await this.prisma.agentMemory.findUnique({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
});
if (!entry) {
throw new NotFoundException(`Memory key "${key}" not found for agent "${agentId}"`);
}
await this.prisma.agentMemory.delete({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
});
return { message: "Memory entry deleted successfully" };
}
}

View File

@@ -1 +0,0 @@
export * from "./upsert-agent-memory.dto";

View File

@@ -1,10 +0,0 @@
import { IsNotEmpty } from "class-validator";
/**
* DTO for upserting an agent memory entry.
* The value accepts any JSON-serializable data.
*/
export class UpsertAgentMemoryDto {
@IsNotEmpty({ message: "value must not be empty" })
value!: unknown;
}

View File

@@ -2,7 +2,6 @@ import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core"; import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerModule } from "@nestjs/throttler";
import { BullModule } from "@nestjs/bullmq"; import { BullModule } from "@nestjs/bullmq";
import { ScheduleModule } from "@nestjs/schedule";
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler"; import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
import { CsrfGuard } from "./common/guards/csrf.guard"; import { CsrfGuard } from "./common/guards/csrf.guard";
import { CsrfService } from "./common/services/csrf.service"; import { CsrfService } from "./common/services/csrf.service";
@@ -28,8 +27,6 @@ import { LlmUsageModule } from "./llm-usage/llm-usage.module";
import { BrainModule } from "./brain/brain.module"; import { BrainModule } from "./brain/brain.module";
import { CronModule } from "./cron/cron.module"; import { CronModule } from "./cron/cron.module";
import { AgentTasksModule } from "./agent-tasks/agent-tasks.module"; import { AgentTasksModule } from "./agent-tasks/agent-tasks.module";
import { FindingsModule } from "./findings/findings.module";
import { AgentMemoryModule } from "./agent-memory/agent-memory.module";
import { ValkeyModule } from "./valkey/valkey.module"; import { ValkeyModule } from "./valkey/valkey.module";
import { BullMqModule } from "./bullmq/bullmq.module"; import { BullMqModule } from "./bullmq/bullmq.module";
import { StitcherModule } from "./stitcher/stitcher.module"; import { StitcherModule } from "./stitcher/stitcher.module";
@@ -40,25 +37,9 @@ import { JobStepsModule } from "./job-steps/job-steps.module";
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module"; import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
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 { CryptoModule } from "./crypto/crypto.module";
import { MosaicTelemetryModule } from "./mosaic-telemetry"; import { MosaicTelemetryModule } from "./mosaic-telemetry";
import { SpeechModule } from "./speech/speech.module"; 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 { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
import { AgentConfigModule } from "./agent-config/agent-config.module";
import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module";
import { ContainerReaperModule } from "./container-reaper/container-reaper.module";
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
import { OnboardingModule } from "./onboarding/onboarding.module";
import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
import { OrchestratorModule } from "./orchestrator/orchestrator.module";
@Module({ @Module({
imports: [ imports: [
@@ -89,7 +70,6 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
}; };
})(), })(),
}), }),
ScheduleModule.forRoot(),
TelemetryModule, TelemetryModule,
PrismaModule, PrismaModule,
DatabaseModule, DatabaseModule,
@@ -113,32 +93,14 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
BrainModule, BrainModule,
CronModule, CronModule,
AgentTasksModule, AgentTasksModule,
FindingsModule,
AgentMemoryModule,
RunnerJobsModule, RunnerJobsModule,
JobEventsModule, JobEventsModule,
JobStepsModule, JobStepsModule,
CoordinatorIntegrationModule, CoordinatorIntegrationModule,
FederationModule, FederationModule,
CredentialsModule, CredentialsModule,
CryptoModule,
MosaicTelemetryModule, MosaicTelemetryModule,
SpeechModule, SpeechModule,
DashboardModule,
TerminalModule,
PersonalitiesModule,
WorkspacesModule,
AdminModule,
TeamsModule,
ImportModule,
ConversationArchiveModule,
AgentConfigModule,
ContainerLifecycleModule,
ContainerReaperModule,
FleetSettingsModule,
OnboardingModule,
ChatProxyModule,
OrchestratorModule,
], ],
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

@@ -18,13 +18,7 @@ vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args), prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
})); }));
import { import { isOidcEnabled, validateOidcConfig, createAuth, getTrustedOrigins } from "./auth.config";
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
@@ -38,7 +32,6 @@ describe("auth.config", () => {
delete process.env.OIDC_CLIENT_SECRET; delete process.env.OIDC_CLIENT_SECRET;
delete process.env.OIDC_REDIRECT_URI; delete process.env.OIDC_REDIRECT_URI;
delete process.env.NODE_ENV; delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_URL;
delete process.env.NEXT_PUBLIC_APP_URL; delete process.env.NEXT_PUBLIC_APP_URL;
delete process.env.NEXT_PUBLIC_API_URL; delete process.env.NEXT_PUBLIC_API_URL;
delete process.env.TRUSTED_ORIGINS; delete process.env.TRUSTED_ORIGINS;
@@ -102,7 +95,7 @@ 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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/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 +104,7 @@ 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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID"); expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
}); });
@@ -119,7 +112,7 @@ 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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET"); expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
}); });
@@ -153,7 +146,7 @@ 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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER"); expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
}); });
@@ -162,7 +155,7 @@ 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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/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 +165,7 @@ 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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow(); expect(() => validateOidcConfig()).not.toThrow();
}); });
@@ -196,30 +189,30 @@ describe("auth.config", () => {
expect(() => validateOidcConfig()).toThrow("Parse error:"); expect(() => validateOidcConfig()).toThrow("Parse error:");
}); });
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/oauth2/callback", () => { it("should throw when OIDC_REDIRECT_URI path does not start with /auth/callback", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
expect(() => validateOidcConfig()).toThrow( expect(() => validateOidcConfig()).toThrow(
'OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback"' 'OIDC_REDIRECT_URI path must start with "/auth/callback"'
); );
expect(() => validateOidcConfig()).toThrow("/oauth/callback"); expect(() => validateOidcConfig()).toThrow("/oauth/callback");
}); });
it("should accept a valid OIDC_REDIRECT_URI with /auth/oauth2/callback path", () => { it("should accept a valid OIDC_REDIRECT_URI with /auth/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow(); expect(() => validateOidcConfig()).not.toThrow();
}); });
it("should accept OIDC_REDIRECT_URI with exactly /auth/oauth2/callback path", () => { it("should accept OIDC_REDIRECT_URI with exactly /auth/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback";
expect(() => validateOidcConfig()).not.toThrow(); expect(() => validateOidcConfig()).not.toThrow();
}); });
it("should warn but not throw when using localhost in production", () => { it("should warn but not throw when using localhost in production", () => {
process.env.NODE_ENV = "production"; process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik"; process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -233,7 +226,7 @@ describe("auth.config", () => {
it("should warn but not throw when using 127.0.0.1 in production", () => { it("should warn but not throw when using 127.0.0.1 in production", () => {
process.env.NODE_ENV = "production"; process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/oauth2/callback/authentik"; process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -247,7 +240,7 @@ describe("auth.config", () => {
it("should not warn about localhost when not in production", () => { it("should not warn about localhost when not in production", () => {
process.env.NODE_ENV = "development"; process.env.NODE_ENV = "development";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik"; process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -272,19 +265,16 @@ 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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
const mockPrisma = {} as PrismaClient; const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma); createAuth(mockPrisma);
expect(mockGenericOAuth).toHaveBeenCalledOnce(); expect(mockGenericOAuth).toHaveBeenCalledOnce();
const callArgs = mockGenericOAuth.mock.calls[0][0] as { const callArgs = mockGenericOAuth.mock.calls[0][0] as {
config: Array<{ pkce?: boolean; redirectURI?: string }>; config: Array<{ pkce?: boolean }>;
}; };
expect(callArgs.config[0].pkce).toBe(true); 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", () => { it("should not call genericOAuth when OIDC is disabled", () => {
@@ -300,7 +290,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true"; process.env.OIDC_ENABLED = "true";
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_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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
// OIDC_CLIENT_ID deliberately not set // OIDC_CLIENT_ID deliberately not set
// validateOidcConfig will throw first, so we need to bypass it // validateOidcConfig will throw first, so we need to bypass it
@@ -317,7 +307,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true"; process.env.OIDC_ENABLED = "true";
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_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
// OIDC_CLIENT_SECRET deliberately not set // OIDC_CLIENT_SECRET deliberately not set
const mockPrisma = {} as PrismaClient; const mockPrisma = {} as PrismaClient;
@@ -328,7 +318,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true"; process.env.OIDC_ENABLED = "true";
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"; process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
// OIDC_ISSUER deliberately not set // OIDC_ISSUER deliberately not set
const mockPrisma = {} as PrismaClient; const mockPrisma = {} as PrismaClient;
@@ -364,7 +354,8 @@ describe("auth.config", () => {
}); });
it("should parse TRUSTED_ORIGINS comma-separated values", () => { it("should parse TRUSTED_ORIGINS comma-separated values", () => {
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,https://api.mosaicstack.dev"; process.env.TRUSTED_ORIGINS =
"https://app.mosaicstack.dev,https://api.mosaicstack.dev";
const origins = getTrustedOrigins(); const origins = getTrustedOrigins();
@@ -373,7 +364,8 @@ describe("auth.config", () => {
}); });
it("should trim whitespace from TRUSTED_ORIGINS entries", () => { it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
process.env.TRUSTED_ORIGINS = " https://app.mosaicstack.dev , https://api.mosaicstack.dev "; process.env.TRUSTED_ORIGINS =
" https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
const origins = getTrustedOrigins(); const origins = getTrustedOrigins();
@@ -524,21 +516,6 @@ describe("auth.config", () => {
expect(config.session.updateAge).toBe(7200); 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", () => { it("should set httpOnly cookie attribute to true", () => {
const mockPrisma = {} as PrismaClient; const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma); createAuth(mockPrisma);
@@ -575,7 +552,6 @@ describe("auth.config", () => {
it("should set secure cookie attribute to true in production", () => { it("should set secure cookie attribute to true in production", () => {
process.env.NODE_ENV = "production"; process.env.NODE_ENV = "production";
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
const mockPrisma = {} as PrismaClient; const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma); createAuth(mockPrisma);
@@ -648,69 +624,4 @@ describe("auth.config", () => {
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined(); 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

@@ -13,41 +13,6 @@ const REQUIRED_OIDC_ENV_VARS = [
"OIDC_REDIRECT_URI", "OIDC_REDIRECT_URI",
] as const; ] 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
*/ */
@@ -93,17 +58,17 @@ export function validateOidcConfig(): void {
); );
} }
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path // Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/callback path
validateRedirectUri(); validateRedirectUri();
} }
/** /**
* Validates the OIDC_REDIRECT_URI environment variable. * Validates the OIDC_REDIRECT_URI environment variable.
* - Must be a parseable URL * - Must be a parseable URL
* - Path must start with /auth/oauth2/callback * - Path must start with /auth/callback
* - Warns (but does not throw) if using localhost in production * - 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 * @throws Error if URL is invalid or path does not start with /auth/callback
*/ */
function validateRedirectUri(): void { function validateRedirectUri(): void {
const redirectUri = process.env.OIDC_REDIRECT_URI; const redirectUri = process.env.OIDC_REDIRECT_URI;
@@ -120,14 +85,14 @@ function validateRedirectUri(): void {
throw new Error( throw new Error(
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` + `OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
`Parse error: ${detail}. ` + `Parse error: ${detail}. ` +
`Example: "https://api.example.com/auth/oauth2/callback/authentik".` `Example: "https://app.example.com/auth/callback/authentik".`
); );
} }
if (!parsed.pathname.startsWith("/auth/oauth2/callback")) { if (!parsed.pathname.startsWith("/auth/callback")) {
throw new Error( throw new Error(
`OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` + `OIDC_REDIRECT_URI path must start with "/auth/callback". Current path: "${parsed.pathname}". ` +
`Example: "https://api.example.com/auth/oauth2/callback/authentik".` `Example: "https://app.example.com/auth/callback/authentik".`
); );
} }
@@ -154,7 +119,6 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
const clientId = process.env.OIDC_CLIENT_ID; const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET; const clientSecret = process.env.OIDC_CLIENT_SECRET;
const issuer = process.env.OIDC_ISSUER; const issuer = process.env.OIDC_ISSUER;
const redirectUri = process.env.OIDC_REDIRECT_URI;
if (!clientId) { if (!clientId) {
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set."); throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
@@ -165,9 +129,6 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
if (!issuer) { if (!issuer) {
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set."); 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({
@@ -177,7 +138,6 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
clientId, clientId,
clientSecret, clientSecret,
discoveryUrl: `${issuer}.well-known/openid-configuration`, discoveryUrl: `${issuer}.well-known/openid-configuration`,
redirectURI: redirectUri,
pkce: true, pkce: true,
scopes: ["openid", "profile", "email"], scopes: ["openid", "profile", "email"],
}, },
@@ -242,10 +202,7 @@ 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", basePath: "/auth",
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: "postgresql", provider: "postgresql",
@@ -254,19 +211,11 @@ export function createAuth(prisma: PrismaClient) {
enabled: true, enabled: true,
}, },
plugins: [...getOidcPlugins()], plugins: [...getOidcPlugins()],
logger: {
disabled: false,
level: "error",
},
session: { session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
}, },
advanced: { advanced: {
database: {
// BetterAuth's default ID generator emits opaque strings; our auth tables use UUID PKs.
generateId: "uuid",
},
defaultCookieAttributes: { defaultCookieAttributes: {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",

View File

@@ -102,46 +102,11 @@ describe("AuthController", () => {
expect(err).toBeInstanceOf(HttpException); expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect((err as HttpException).getResponse()).toBe( expect((err as HttpException).getResponse()).toBe(
"Unable to complete authentication. Please try again in a moment." "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 () => { it("should log warning and not throw when handler throws after headers sent", async () => {
const handlerError = new Error("Stream interrupted"); const handlerError = new Error("Stream interrupted");
mockNodeHandler.mockRejectedValueOnce(handlerError); mockNodeHandler.mockRejectedValueOnce(handlerError);
@@ -177,7 +142,9 @@ describe("AuthController", () => {
headersSent: false, headersSent: false,
} as unknown as ExpressResponse; } as unknown as ExpressResponse;
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException); await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(
HttpException,
);
}); });
}); });
@@ -220,7 +187,7 @@ describe("AuthController", () => {
OIDC_CLIENT_SECRET: "test-client-secret", OIDC_CLIENT_SECRET: "test-client-secret",
OIDC_CLIENT_ID: "test-client-id", OIDC_CLIENT_ID: "test-client-id",
OIDC_ISSUER: "https://auth.test.com/", OIDC_ISSUER: "https://auth.test.com/",
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik", OIDC_REDIRECT_URI: "https://app.test.com/auth/callback/authentik",
BETTER_AUTH_SECRET: "test-better-auth-secret", BETTER_AUTH_SECRET: "test-better-auth-secret",
JWT_SECRET: "test-jwt-secret", JWT_SECRET: "test-jwt-secret",
CSRF_SECRET: "test-csrf-secret", CSRF_SECRET: "test-csrf-secret",
@@ -329,9 +296,11 @@ describe("AuthController", () => {
}, },
}; };
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow( expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context" UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
); );
}); });
@@ -344,30 +313,37 @@ describe("AuthController", () => {
}, },
}; };
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow( expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context" UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
); );
}); });
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => { it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
const mockRequest = {}; const mockRequest = {};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow( expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context" 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 +354,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,11 +373,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");
}); });
}); });
@@ -421,7 +401,9 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse); await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50")); expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
}); });
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => { it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
@@ -441,9 +423,13 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse); await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50")); expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
// Ensure it does NOT contain the second IP in the extracted position // Ensure it does NOT contain the second IP in the extracted position
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18")); expect(debugSpy).toHaveBeenCalledWith(
expect.not.stringContaining("70.41.3.18"),
);
}); });
it("should extract first IP from X-Forwarded-For as array", async () => { it("should extract first IP from X-Forwarded-For as array", async () => {
@@ -463,7 +449,9 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse); await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50")); expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
}); });
it("should fallback to req.ip when no X-Forwarded-For header", async () => { it("should fallback to req.ip when no X-Forwarded-For header", async () => {
@@ -483,7 +471,9 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse); await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100")); expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("192.168.1.100"),
);
}); });
}); });
}); });

View File

@@ -72,10 +72,15 @@ 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;
} }
@@ -106,7 +111,7 @@ export class AuthController {
// @SkipCsrf avoids double-protection conflicts. // @SkipCsrf avoids double-protection conflicts.
// See: https://www.better-auth.com/docs/reference/security // See: https://www.better-auth.com/docs/reference/security
@SkipCsrf() @SkipCsrf()
@Throttle({ default: { ttl: 60_000, limit: 5 } }) @Throttle({ strict: { limit: 10, ttl: 60000 } })
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> { async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> {
// Extract client IP for logging // Extract client IP for logging
const clientIp = this.getClientIp(req); const clientIp = this.getClientIp(req);
@@ -118,14 +123,6 @@ export class AuthController {
try { try {
await handler(req, res); 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) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined; const stack = error instanceof Error ? error.stack : undefined;
@@ -136,11 +133,6 @@ export class AuthController {
); );
if (!res.headersSent) { if (!res.headersSent) {
const mappedError = this.mapToHttpException(error);
if (mappedError) {
throw mappedError;
}
throw new HttpException( throw new HttpException(
"Unable to complete authentication. Please try again in a moment.", "Unable to complete authentication. Please try again in a moment.",
HttpStatus.INTERNAL_SERVER_ERROR HttpStatus.INTERNAL_SERVER_ERROR
@@ -167,45 +159,4 @@ export class AuthController {
// Fall back to direct IP // Fall back to direct IP
return req.ip ?? req.socket.remoteAddress ?? "unknown"; return req.ip ?? req.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

@@ -410,7 +410,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 +418,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",
}, },
@@ -568,10 +517,14 @@ describe("AuthService", () => {
it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => { it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
const auth = service.getAuth(); const auth = service.getAuth();
const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired")); const mockGetSession = vi
.fn()
.mockRejectedValue(new Error("certificate has expired"));
auth.api = { getSession: mockGetSession } as any; auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired"); 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 () => { it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {

View File

@@ -21,10 +21,6 @@ interface VerifiedSession {
session: 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);
@@ -107,27 +103,36 @@ export class AuthService {
* Only known-safe auth errors return null; everything else propagates as 500. * Only known-safe auth errors return null; everything else propagates as 500.
*/ */
async verifySession(token: string): Promise<VerifiedSession | null> { async verifySession(token: string): Promise<VerifiedSession | null> {
let sawNonError = false; try {
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
const session = await this.auth.api.getSession({
headers: {
authorization: `Bearer ${token}`,
},
});
for (const candidate of this.buildSessionHeaderCandidates(token)) { if (!session) {
try { return null;
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces }
const session = await this.auth.api.getSession(candidate);
if (!session) { return {
continue; user: session.user as Record<string, unknown>,
} session: session.session as Record<string, unknown>,
};
return { } catch (error: unknown) {
user: session.user as Record<string, unknown>, // Only known-safe auth errors return null
session: session.session as Record<string, unknown>, if (error instanceof Error) {
}; const msg = error.message.toLowerCase();
} catch (error: unknown) { const isExpectedAuthError =
if (error instanceof Error) { msg.includes("invalid token") ||
if (this.isExpectedAuthError(error.message)) { msg.includes("token expired") ||
continue; msg.includes("session expired") ||
} msg.includes("session not found") ||
msg.includes("invalid session") ||
msg === "unauthorized" ||
msg === "expired";
if (!isExpectedAuthError) {
// Infrastructure or unexpected — propagate as 500 // Infrastructure or unexpected — propagate as 500
const safeMessage = (error.stack ?? error.message).replace( const safeMessage = (error.stack ?? error.message).replace(
/Bearer\s+\S+/gi, /Bearer\s+\S+/gi,
@@ -136,55 +141,14 @@ export class AuthService {
this.logger.error("Session verification failed due to unexpected error", safeMessage); this.logger.error("Session verification failed due to unexpected error", safeMessage);
throw error; 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;
}
} }
// Non-Error thrown values — log for observability, treat as auth failure
if (!(error instanceof Error)) {
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
}
return null;
} }
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: {
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"
);
} }
/** /**

View File

@@ -1,18 +1,10 @@
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"; import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
@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> {
@@ -67,8 +59,7 @@ 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: MaybeAuthenticatedRequest): string | undefined {
// Express types `cookies` as `any`; cast to a known shape for type safety. // Express types `cookies` as `any`; cast to a known shape for type safety.
@@ -77,23 +68,8 @@ export class AuthGuard implements CanActivate {
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 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;
} }
/** /**

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,102 +0,0 @@
import {
Body,
Controller,
HttpException,
Logger,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import type { Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
import { ChatStreamDto } from "./chat-proxy.dto";
import { ChatProxyService } from "./chat-proxy.service";
@Controller("chat")
@UseGuards(AuthGuard)
export class ChatProxyController {
private readonly logger = new Logger(ChatProxyController.name);
constructor(private readonly chatProxyService: ChatProxyService) {}
// POST /api/chat/stream
// Request: { messages: Array<{role, content}> }
// Response: SSE stream of chat completion events
@Post("stream")
async streamChat(
@Body() body: ChatStreamDto,
@Req() req: MaybeAuthenticatedRequest,
@Res() res: Response
): Promise<void> {
const userId = req.user?.id;
if (!userId) {
throw new UnauthorizedException("No authenticated user found on request");
}
const abortController = new AbortController();
req.once("close", () => {
abortController.abort();
});
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
try {
const upstreamResponse = await this.chatProxyService.proxyChat(
userId,
body.messages,
abortController.signal
);
const upstreamContentType = upstreamResponse.headers.get("content-type");
if (upstreamContentType) {
res.setHeader("Content-Type", upstreamContentType);
}
if (!upstreamResponse.body) {
throw new Error("OpenClaw response did not include a stream body");
}
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
if (res.writableEnded || res.destroyed) {
break;
}
res.write(Buffer.from(chunk));
}
} catch (error: unknown) {
this.logStreamError(error);
if (!res.writableEnded && !res.destroyed) {
res.write("event: error\n");
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
}
} finally {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
}
private toSafeClientMessage(error: unknown): string {
if (error instanceof HttpException && error.getStatus() < 500) {
return "Chat request was rejected";
}
return "Chat stream failed";
}
private logStreamError(error: unknown): void {
if (error instanceof Error) {
this.logger.warn(`Chat stream failed: ${error.message}`);
return;
}
this.logger.warn(`Chat stream failed: ${String(error)}`);
}
}

View File

@@ -1,25 +0,0 @@
import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator";
export interface ChatMessage {
role: string;
content: string;
}
export class ChatMessageDto implements ChatMessage {
@IsString({ message: "role must be a string" })
@IsNotEmpty({ message: "role is required" })
role!: string;
@IsString({ message: "content must be a string" })
@IsNotEmpty({ message: "content is required" })
content!: string;
}
export class ChatStreamDto {
@IsArray({ message: "messages must be an array" })
@ArrayMinSize(1, { message: "messages must contain at least one message" })
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
}

View File

@@ -1,15 +0,0 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { AgentConfigModule } from "../agent-config/agent-config.module";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
import { PrismaModule } from "../prisma/prisma.module";
import { ChatProxyController } from "./chat-proxy.controller";
import { ChatProxyService } from "./chat-proxy.service";
@Module({
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
controllers: [ChatProxyController],
providers: [ChatProxyService],
exports: [ChatProxyService],
})
export class ChatProxyModule {}

View File

@@ -1,108 +0,0 @@
import { ServiceUnavailableException } from "@nestjs/common";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatProxyService } from "./chat-proxy.service";
describe("ChatProxyService", () => {
const userId = "user-123";
const prisma = {
userAgentConfig: {
findUnique: vi.fn(),
},
};
const containerLifecycle = {
ensureRunning: vi.fn(),
touch: vi.fn(),
};
let service: ChatProxyService;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
service = new ChatProxyService(prisma as never, containerLifecycle as never);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("getContainerUrl", () => {
it("calls ensureRunning and touch for the user", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
const url = await service.getContainerUrl(userId);
expect(url).toBe("http://mosaic-user-user-123:19000");
expect(containerLifecycle.ensureRunning).toHaveBeenCalledWith(userId);
expect(containerLifecycle.touch).toHaveBeenCalledWith(userId);
});
});
describe("proxyChat", () => {
it("forwards the request to the user's OpenClaw container", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello from Mosaic" }];
const response = await service.proxyChat(userId, messages);
expect(response).toBeInstanceOf(Response);
expect(fetchMock).toHaveBeenCalledWith(
"http://mosaic-user-user-123:19000/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: {
Authorization: "Bearer gateway-token",
"Content-Type": "application/json",
},
})
);
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody).toEqual({
messages,
model: "openclaw:default",
stream: true,
});
});
it("throws ServiceUnavailableException on connection refused errors", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:19000"));
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
ServiceUnavailableException
);
});
it("throws ServiceUnavailableException on timeout errors", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout"));
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
ServiceUnavailableException
);
});
});
});

View File

@@ -1,110 +0,0 @@
import {
BadGatewayException,
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
import { PrismaService } from "../prisma/prisma.service";
import type { ChatMessage } from "./chat-proxy.dto";
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
interface ContainerConnection {
url: string;
token: string;
}
@Injectable()
export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly containerLifecycle: ContainerLifecycleService
) {}
// Get the user's OpenClaw container URL and mark it active.
async getContainerUrl(userId: string): Promise<string> {
const { url } = await this.getContainerConnection(userId);
return url;
}
// Proxy chat request to OpenClaw.
async proxyChat(
userId: string,
messages: ChatMessage[],
signal?: AbortSignal
): Promise<Response> {
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
const model = await this.getPreferredModel(userId);
const requestInit: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`,
},
body: JSON.stringify({
messages,
model,
stream: true,
}),
};
if (signal) {
requestInit.signal = signal;
}
try {
const response = await fetch(`${containerUrl}/v1/chat/completions`, requestInit);
if (!response.ok) {
const detail = await this.readResponseText(response);
const status = `${String(response.status)} ${response.statusText}`.trim();
this.logger.warn(
detail ? `OpenClaw returned ${status}: ${detail}` : `OpenClaw returned ${status}`
);
throw new BadGatewayException(`OpenClaw returned ${status}`);
}
return response;
} catch (error: unknown) {
if (error instanceof BadGatewayException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to proxy chat request: ${message}`);
throw new ServiceUnavailableException("Failed to proxy chat to OpenClaw");
}
}
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
const connection = await this.containerLifecycle.ensureRunning(userId);
await this.containerLifecycle.touch(userId);
return connection;
}
private async getPreferredModel(userId: string): Promise<string> {
const config = await this.prisma.userAgentConfig.findUnique({
where: { userId },
select: { primaryModel: true },
});
const primaryModel = config?.primaryModel?.trim();
if (!primaryModel) {
return DEFAULT_OPENCLAW_MODEL;
}
return primaryModel;
}
private async readResponseText(response: Response): Promise<string | null> {
try {
const text = (await response.text()).trim();
return text.length > 0 ? text : null;
} catch {
return null;
}
}
}

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

@@ -87,17 +87,6 @@ describe("CsrfGuard", () => {
}); });
describe("State-changing methods requiring CSRF", () => { describe("State-changing methods requiring CSRF", () => {
it("should allow POST with Bearer auth without CSRF token", () => {
const context = createContext(
"POST",
{},
{ authorization: "Bearer api-token" },
false,
"user-123"
);
expect(guard.canActivate(context)).toBe(true);
});
it("should reject POST without CSRF token", () => { it("should reject POST without CSRF token", () => {
const context = createContext("POST", {}, {}, false, "user-123"); const context = createContext("POST", {}, {}, false, "user-123");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
@@ -185,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

@@ -57,11 +57,6 @@ export class CsrfGuard implements CanActivate {
return true; return true;
} }
const authHeader = request.headers.authorization;
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
return true;
}
// Get CSRF token from cookie and header // Get CSRF token from cookie and header
const cookies = request.cookies as Record<string, string> | undefined; const cookies = request.cookies as Record<string, string> | undefined;
const cookieToken = cookies?.["csrf-token"]; const cookieToken = cookies?.["csrf-token"];
@@ -94,26 +89,31 @@ 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,
method: request.method, path: request.path,
path: request.path, securityEvent: true,
securityEvent: true, timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(), });
});
throw new ForbiddenException("CSRF token not bound to session"); 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");
} }
// Note: when userId is absent, the double-submit cookie check above is
// sufficient CSRF protection. AuthGuard populates request.user afterward.
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

@@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { ContainerLifecycleService } from "./container-lifecycle.service";
@Module({
imports: [ConfigModule, PrismaModule, CryptoModule],
providers: [ContainerLifecycleService],
exports: [ContainerLifecycleService],
})
export class ContainerLifecycleModule {}

View File

@@ -1,593 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfigService } from "@nestjs/config";
import type { PrismaService } from "../prisma/prisma.service";
import type { CryptoService } from "../crypto/crypto.service";
interface MockUserContainerRecord {
id: string;
userId: string;
containerId: string | null;
containerName: string;
gatewayPort: number | null;
gatewayToken: string;
status: string;
lastActiveAt: Date | null;
idleTimeoutMin: number;
config: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
const dockerMock = vi.hoisted(() => {
interface MockDockerContainerState {
id: string;
name: string;
running: boolean;
port: number;
}
const containers = new Map<string, MockDockerContainerState>();
const handles = new Map<
string,
{
inspect: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
}
>();
const ensureHandle = (id: string) => {
const existing = handles.get(id);
if (existing) {
return existing;
}
const handle = {
inspect: vi.fn(async () => {
const container = containers.get(id);
if (!container) {
throw { statusCode: 404 };
}
return {
Id: container.id,
State: {
Running: container.running,
},
NetworkSettings: {
Ports: {
"18789/tcp": [{ HostPort: String(container.port) }],
},
},
};
}),
start: vi.fn(async () => {
const container = containers.get(id);
if (!container) {
throw { statusCode: 404 };
}
container.running = true;
}),
stop: vi.fn(async () => {
const container = containers.get(id);
if (!container) {
throw { statusCode: 404 };
}
container.running = false;
}),
};
handles.set(id, handle);
return handle;
};
const listContainers = vi.fn(
async (options?: { all?: boolean; filters?: { name?: string[] } }) => {
const nameFilter = options?.filters?.name?.[0];
return [...containers.values()]
.filter((container) => (nameFilter ? container.name.includes(nameFilter) : true))
.map((container) => ({
Id: container.id,
Names: [`/${container.name}`],
}));
}
);
const getContainer = vi.fn((id: string) => ensureHandle(id));
const createContainer = vi.fn(
async (options: {
name?: string;
HostConfig?: { PortBindings?: Record<string, Array<{ HostPort?: string }>> };
}) => {
const id = `ctr-${containers.size + 1}`;
const name = options.name ?? id;
const hostPort = options.HostConfig?.PortBindings?.["18789/tcp"]?.[0]?.HostPort;
const port = hostPort ? Number.parseInt(hostPort, 10) : 0;
containers.set(id, {
id,
name,
running: false,
port,
});
return ensureHandle(id);
}
);
const dockerInstance = {
listContainers,
getContainer,
createContainer,
};
const constructorSpy = vi.fn();
class DockerConstructorMock {
constructor(options?: unknown) {
constructorSpy(options);
return dockerInstance;
}
}
const registerContainer = (container: MockDockerContainerState) => {
containers.set(container.id, { ...container });
ensureHandle(container.id);
};
const reset = () => {
containers.clear();
handles.clear();
constructorSpy.mockClear();
listContainers.mockClear();
getContainer.mockClear();
createContainer.mockClear();
};
return {
DockerConstructorMock,
constructorSpy,
createContainer,
handles,
registerContainer,
reset,
};
});
vi.mock("dockerode", () => ({
default: dockerMock.DockerConstructorMock,
}));
import { ContainerLifecycleService } from "./container-lifecycle.service";
function createConfigMock(values: Record<string, string> = {}) {
return {
get: vi.fn((key: string) => values[key]),
};
}
function createCryptoMock() {
return {
generateToken: vi.fn(() => "generated-token"),
encrypt: vi.fn((value: string) => `enc:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
};
}
function projectRecord(
record: MockUserContainerRecord,
select?: Record<string, boolean>
): Partial<MockUserContainerRecord> {
if (!select) {
return { ...record };
}
const projection: Partial<MockUserContainerRecord> = {};
for (const [field, enabled] of Object.entries(select)) {
if (enabled) {
const key = field as keyof MockUserContainerRecord;
projection[key] = record[key];
}
}
return projection;
}
function createPrismaMock(initialRecords: MockUserContainerRecord[] = []) {
const records = new Map<string, MockUserContainerRecord>();
for (const record of initialRecords) {
records.set(record.userId, { ...record });
}
const userContainer = {
findUnique: vi.fn(
async (args: {
where: { userId?: string; id?: string };
select?: Record<string, boolean>;
}) => {
let record: MockUserContainerRecord | undefined;
if (args.where.userId) {
record = records.get(args.where.userId);
} else if (args.where.id) {
record = [...records.values()].find((entry) => entry.id === args.where.id);
}
if (!record) {
return null;
}
return projectRecord(record, args.select);
}
),
create: vi.fn(
async (args: {
data: Partial<MockUserContainerRecord> & {
userId: string;
containerName: string;
gatewayToken: string;
};
}) => {
const now = new Date();
const next: MockUserContainerRecord = {
id: args.data.id ?? `uc-${records.size + 1}`,
userId: args.data.userId,
containerId: args.data.containerId ?? null,
containerName: args.data.containerName,
gatewayPort: args.data.gatewayPort ?? null,
gatewayToken: args.data.gatewayToken,
status: args.data.status ?? "stopped",
lastActiveAt: args.data.lastActiveAt ?? null,
idleTimeoutMin: args.data.idleTimeoutMin ?? 30,
config: args.data.config ?? {},
createdAt: now,
updatedAt: now,
};
records.set(next.userId, next);
return { ...next };
}
),
update: vi.fn(
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
const record = records.get(args.where.userId);
if (!record) {
throw new Error(`Record ${args.where.userId} not found`);
}
const updated: MockUserContainerRecord = {
...record,
...args.data,
updatedAt: new Date(),
};
records.set(updated.userId, updated);
return { ...updated };
}
),
updateMany: vi.fn(
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
const record = records.get(args.where.userId);
if (!record) {
return { count: 0 };
}
const updated: MockUserContainerRecord = {
...record,
...args.data,
updatedAt: new Date(),
};
records.set(updated.userId, updated);
return { count: 1 };
}
),
findMany: vi.fn(
async (args?: {
where?: {
status?: string;
lastActiveAt?: { not: null };
gatewayPort?: { not: null };
};
select?: Record<string, boolean>;
}) => {
let rows = [...records.values()];
if (args?.where?.status) {
rows = rows.filter((record) => record.status === args.where?.status);
}
if (args?.where?.lastActiveAt?.not === null) {
rows = rows.filter((record) => record.lastActiveAt !== null);
}
if (args?.where?.gatewayPort?.not === null) {
rows = rows.filter((record) => record.gatewayPort !== null);
}
return rows.map((record) => projectRecord(record, args?.select));
}
),
};
return {
prisma: {
userContainer,
},
records,
};
}
function createRecord(overrides: Partial<MockUserContainerRecord>): MockUserContainerRecord {
const now = new Date();
return {
id: overrides.id ?? "uc-default",
userId: overrides.userId ?? "user-default",
containerId: overrides.containerId ?? null,
containerName: overrides.containerName ?? "mosaic-user-user-default",
gatewayPort: overrides.gatewayPort ?? null,
gatewayToken: overrides.gatewayToken ?? "enc:token-default",
status: overrides.status ?? "stopped",
lastActiveAt: overrides.lastActiveAt ?? null,
idleTimeoutMin: overrides.idleTimeoutMin ?? 30,
config: overrides.config ?? {},
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
};
}
describe("ContainerLifecycleService", () => {
beforeEach(() => {
dockerMock.reset();
});
it("ensureRunning creates container when none exists", async () => {
const { prisma, records } = createPrismaMock();
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
const result = await service.ensureRunning("user-1");
expect(result).toEqual({
url: "http://mosaic-user-user-1:19000",
token: "generated-token",
});
const updatedRecord = records.get("user-1");
expect(updatedRecord?.status).toBe("running");
expect(updatedRecord?.containerId).toBe("ctr-1");
expect(updatedRecord?.gatewayPort).toBe(19000);
expect(updatedRecord?.gatewayToken).toBe("enc:generated-token");
expect(dockerMock.createContainer).toHaveBeenCalledTimes(1);
const [createCall] = dockerMock.createContainer.mock.calls[0] as [
{
name: string;
Image: string;
Env: string[];
HostConfig: { Binds: string[]; NetworkMode: string };
},
];
expect(createCall.name).toBe("mosaic-user-user-1");
expect(createCall.Image).toBe("alpine/openclaw:latest");
expect(createCall.HostConfig.Binds).toEqual(["mosaic-user-user-1-state:/home/node/.openclaw"]);
expect(createCall.HostConfig.NetworkMode).toBe("mosaic-internal");
expect(createCall.Env).toContain("AGENT_TOKEN=generated-token");
});
it("ensureRunning starts existing stopped container", async () => {
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-1",
userId: "user-2",
containerId: "ctr-stopped",
containerName: "mosaic-user-user-2",
gatewayToken: "enc:existing-token",
status: "stopped",
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-stopped",
name: "mosaic-user-user-2",
running: false,
port: 19042,
});
const result = await service.ensureRunning("user-2");
expect(result).toEqual({
url: "http://mosaic-user-user-2:19042",
token: "existing-token",
});
const handle = dockerMock.handles.get("ctr-stopped");
expect(handle?.start).toHaveBeenCalledTimes(1);
expect(records.get("user-2")?.status).toBe("running");
expect(records.get("user-2")?.gatewayPort).toBe(19042);
});
it("ensureRunning returns existing running container", async () => {
const { prisma } = createPrismaMock([
createRecord({
id: "uc-2",
userId: "user-3",
containerId: "ctr-running",
containerName: "mosaic-user-user-3",
gatewayPort: 19043,
gatewayToken: "enc:running-token",
status: "running",
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-running",
name: "mosaic-user-user-3",
running: true,
port: 19043,
});
const result = await service.ensureRunning("user-3");
expect(result).toEqual({
url: "http://mosaic-user-user-3:19043",
token: "running-token",
});
expect(dockerMock.createContainer).not.toHaveBeenCalled();
const handle = dockerMock.handles.get("ctr-running");
expect(handle?.start).not.toHaveBeenCalled();
});
it("stop gracefully stops container and updates DB", async () => {
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-stop",
userId: "user-stop",
containerId: "ctr-stop",
containerName: "mosaic-user-user-stop",
gatewayPort: 19044,
status: "running",
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-stop",
name: "mosaic-user-user-stop",
running: true,
port: 19044,
});
await service.stop("user-stop");
const handle = dockerMock.handles.get("ctr-stop");
expect(handle?.stop).toHaveBeenCalledWith({ t: 10 });
const updatedRecord = records.get("user-stop");
expect(updatedRecord?.status).toBe("stopped");
expect(updatedRecord?.containerId).toBeNull();
expect(updatedRecord?.gatewayPort).toBeNull();
});
it("reapIdle stops only containers past their idle timeout", async () => {
const now = Date.now();
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-old",
userId: "user-old",
containerId: "ctr-old",
containerName: "mosaic-user-user-old",
gatewayPort: 19045,
status: "running",
lastActiveAt: new Date(now - 60 * 60 * 1000),
idleTimeoutMin: 30,
}),
createRecord({
id: "uc-fresh",
userId: "user-fresh",
containerId: "ctr-fresh",
containerName: "mosaic-user-user-fresh",
gatewayPort: 19046,
status: "running",
lastActiveAt: new Date(now - 5 * 60 * 1000),
idleTimeoutMin: 30,
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-old",
name: "mosaic-user-user-old",
running: true,
port: 19045,
});
dockerMock.registerContainer({
id: "ctr-fresh",
name: "mosaic-user-user-fresh",
running: true,
port: 19046,
});
const result = await service.reapIdle();
expect(result).toEqual({
stopped: ["user-old"],
});
expect(records.get("user-old")?.status).toBe("stopped");
expect(records.get("user-fresh")?.status).toBe("running");
const oldHandle = dockerMock.handles.get("ctr-old");
const freshHandle = dockerMock.handles.get("ctr-fresh");
expect(oldHandle?.stop).toHaveBeenCalledTimes(1);
expect(freshHandle?.stop).not.toHaveBeenCalled();
});
it("touch updates lastActiveAt", async () => {
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-touch",
userId: "user-touch",
containerName: "mosaic-user-user-touch",
lastActiveAt: null,
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
await service.touch("user-touch");
const updatedRecord = records.get("user-touch");
expect(updatedRecord?.lastActiveAt).toBeInstanceOf(Date);
});
it("getStatus returns null for unknown user", async () => {
const { prisma } = createPrismaMock();
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
const status = await service.getStatus("missing-user");
expect(status).toBeNull();
});
});

View File

@@ -1,532 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Docker from "dockerode";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
const DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
const DEFAULT_DOCKER_TCP_PORT = 2375;
const DEFAULT_OPENCLAW_IMAGE = "alpine/openclaw:latest";
const DEFAULT_OPENCLAW_NETWORK = "mosaic-internal";
const DEFAULT_OPENCLAW_PORT_RANGE_START = 19000;
const DEFAULT_MOSAIC_API_URL = "http://mosaic-api:3000/api";
const OPENCLAW_GATEWAY_PORT_KEY = "18789/tcp";
const OPENCLAW_STATE_PATH = "/home/node/.openclaw";
const CONTAINER_STOP_TIMEOUT_SECONDS = 10;
interface ContainerHandle {
inspect(): Promise<DockerInspect>;
start(): Promise<void>;
stop(options?: { t?: number }): Promise<void>;
}
interface DockerInspect {
Id?: string;
State?: {
Running?: boolean;
Health?: {
Status?: string;
};
};
NetworkSettings?: {
Ports?: Record<string, { HostPort?: string }[] | null>;
};
HostConfig?: {
PortBindings?: Record<string, { HostPort?: string }[] | null>;
};
}
interface UserContainerRecord {
id: string;
userId: string;
containerId: string | null;
containerName: string;
gatewayPort: number | null;
gatewayToken: string;
status: string;
lastActiveAt: Date | null;
idleTimeoutMin: number;
}
interface ContainerLookup {
containerId: string | null;
containerName: string;
}
@Injectable()
export class ContainerLifecycleService {
private readonly logger = new Logger(ContainerLifecycleService.name);
private readonly docker: Docker;
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
private readonly config: ConfigService
) {
const dockerHost = this.config.get<string>("DOCKER_HOST");
this.docker = this.createDockerClient(dockerHost);
}
// Ensure a user's container is running. Creates if needed, starts if stopped.
// Returns the container's internal URL and gateway token.
async ensureRunning(userId: string): Promise<{ url: string; token: string }> {
const containerRecord = await this.getOrCreateContainerRecord(userId);
const token = this.getGatewayToken(containerRecord.gatewayToken);
const existingContainer = await this.resolveContainer(containerRecord);
let container: ContainerHandle;
if (existingContainer) {
container = existingContainer;
const inspect = await container.inspect();
if (!inspect.State?.Running) {
await container.start();
}
} else {
const port = await this.findAvailableGatewayPort();
container = await this.createContainer(containerRecord, token, port);
await container.start();
}
const inspect = await container.inspect();
const containerId = inspect.Id;
if (!containerId) {
throw new Error(
`Docker inspect did not return container ID for ${containerRecord.containerName}`
);
}
const gatewayPort = this.extractGatewayPort(inspect);
if (!gatewayPort) {
throw new Error(`Could not determine gateway port for ${containerRecord.containerName}`);
}
const now = new Date();
await this.prisma.userContainer.update({
where: { userId },
data: {
containerId,
gatewayPort,
status: "running",
lastActiveAt: now,
},
});
return {
url: `http://${containerRecord.containerName}:${String(gatewayPort)}`,
token,
};
}
// Stop a user's container
async stop(userId: string): Promise<void> {
const containerRecord = await this.prisma.userContainer.findUnique({
where: { userId },
});
if (!containerRecord) {
return;
}
const container = await this.resolveContainer(containerRecord);
if (container) {
try {
await container.stop({ t: CONTAINER_STOP_TIMEOUT_SECONDS });
} catch (error) {
if (!this.isDockerNotFound(error) && !this.isAlreadyStopped(error)) {
throw error;
}
}
}
await this.prisma.userContainer.update({
where: { userId },
data: {
status: "stopped",
containerId: null,
gatewayPort: null,
},
});
}
// Stop idle containers (called by cron/scheduler)
async reapIdle(): Promise<{ stopped: string[] }> {
const now = Date.now();
const runningContainers = await this.prisma.userContainer.findMany({
where: {
status: "running",
lastActiveAt: { not: null },
},
select: {
userId: true,
lastActiveAt: true,
idleTimeoutMin: true,
},
});
const stopped: string[] = [];
for (const container of runningContainers) {
const lastActiveAt = container.lastActiveAt;
if (!lastActiveAt) {
continue;
}
const idleLimitMs = container.idleTimeoutMin * 60 * 1000;
if (now - lastActiveAt.getTime() < idleLimitMs) {
continue;
}
try {
await this.stop(container.userId);
stopped.push(container.userId);
} catch (error) {
this.logger.warn(
`Failed to stop idle container for user ${container.userId}: ${this.getErrorMessage(error)}`
);
}
}
return { stopped };
}
// Health check all running containers
async healthCheckAll(): Promise<{ userId: string; healthy: boolean; error?: string }[]> {
const runningContainers = await this.prisma.userContainer.findMany({
where: {
status: "running",
},
select: {
userId: true,
containerId: true,
containerName: true,
},
});
const results: { userId: string; healthy: boolean; error?: string }[] = [];
for (const containerRecord of runningContainers) {
const container = await this.resolveContainer(containerRecord);
if (!container) {
results.push({
userId: containerRecord.userId,
healthy: false,
error: "Container not found",
});
continue;
}
try {
const inspect = await container.inspect();
const isRunning = inspect.State?.Running === true;
const healthState = inspect.State?.Health?.Status;
const healthy = isRunning && healthState !== "unhealthy";
if (healthy) {
results.push({
userId: containerRecord.userId,
healthy: true,
});
continue;
}
results.push({
userId: containerRecord.userId,
healthy: false,
error:
healthState === "unhealthy" ? "Container healthcheck failed" : "Container not running",
});
} catch (error) {
results.push({
userId: containerRecord.userId,
healthy: false,
error: this.getErrorMessage(error),
});
}
}
return results;
}
// Restart a container with fresh config (for config updates)
async restart(userId: string): Promise<void> {
await this.stop(userId);
await this.ensureRunning(userId);
}
// Update lastActiveAt timestamp (called on each chat request)
async touch(userId: string): Promise<void> {
await this.prisma.userContainer.updateMany({
where: { userId },
data: {
lastActiveAt: new Date(),
},
});
}
// Get container status for a user
async getStatus(
userId: string
): Promise<{ status: string; port?: number; lastActive?: Date } | null> {
const container = await this.prisma.userContainer.findUnique({
where: { userId },
select: {
status: true,
gatewayPort: true,
lastActiveAt: true,
},
});
if (!container) {
return null;
}
const status: { status: string; port?: number; lastActive?: Date } = {
status: container.status,
};
if (container.gatewayPort !== null) {
status.port = container.gatewayPort;
}
if (container.lastActiveAt !== null) {
status.lastActive = container.lastActiveAt;
}
return status;
}
private createDockerClient(dockerHost?: string): Docker {
if (!dockerHost || dockerHost.trim().length === 0) {
return new Docker({ socketPath: DEFAULT_DOCKER_SOCKET_PATH });
}
if (dockerHost.startsWith("unix://")) {
return new Docker({ socketPath: dockerHost.slice("unix://".length) });
}
if (dockerHost.startsWith("tcp://")) {
const parsed = new URL(dockerHost.replace("tcp://", "http://"));
return new Docker({
host: parsed.hostname,
port: this.parseInteger(parsed.port, DEFAULT_DOCKER_TCP_PORT),
protocol: "http",
});
}
if (dockerHost.startsWith("http://") || dockerHost.startsWith("https://")) {
const parsed = new URL(dockerHost);
const protocol = parsed.protocol.replace(":", "");
return new Docker({
host: parsed.hostname,
port: this.parseInteger(parsed.port, DEFAULT_DOCKER_TCP_PORT),
protocol: protocol === "https" ? "https" : "http",
});
}
return new Docker({ socketPath: dockerHost });
}
private async getOrCreateContainerRecord(userId: string): Promise<UserContainerRecord> {
const existingContainer = await this.prisma.userContainer.findUnique({
where: { userId },
});
if (existingContainer) {
return existingContainer;
}
const token = this.crypto.generateToken();
const containerName = this.getContainerName(userId);
return this.prisma.userContainer.create({
data: {
userId,
containerName,
gatewayToken: this.crypto.encrypt(token),
status: "stopped",
},
});
}
private getContainerName(userId: string): string {
return `mosaic-user-${userId}`;
}
private getVolumeName(userId: string): string {
return `mosaic-user-${userId}-state`;
}
private getOpenClawImage(): string {
return this.config.get<string>("OPENCLAW_IMAGE") ?? DEFAULT_OPENCLAW_IMAGE;
}
private getOpenClawNetwork(): string {
return this.config.get<string>("OPENCLAW_NETWORK") ?? DEFAULT_OPENCLAW_NETWORK;
}
private getMosaicApiUrl(): string {
return this.config.get<string>("MOSAIC_API_URL") ?? DEFAULT_MOSAIC_API_URL;
}
private getPortRangeStart(): number {
return this.parseInteger(
this.config.get<string>("OPENCLAW_PORT_RANGE_START"),
DEFAULT_OPENCLAW_PORT_RANGE_START
);
}
private async resolveContainer(record: ContainerLookup): Promise<ContainerHandle | null> {
if (record.containerId) {
const byId = this.docker.getContainer(record.containerId) as unknown as ContainerHandle;
if (await this.containerExists(byId)) {
return byId;
}
}
const byName = await this.findContainerByName(record.containerName);
if (byName) {
return byName;
}
return null;
}
private async findContainerByName(containerName: string): Promise<ContainerHandle | null> {
const containers = await this.docker.listContainers({
all: true,
filters: {
name: [containerName],
},
});
const match = containers.find((container) => {
const names = container.Names;
return names.some((name) => name === `/${containerName}` || name.includes(containerName));
});
if (!match?.Id) {
return null;
}
return this.docker.getContainer(match.Id) as unknown as ContainerHandle;
}
private async containerExists(container: ContainerHandle): Promise<boolean> {
try {
await container.inspect();
return true;
} catch (error) {
if (this.isDockerNotFound(error)) {
return false;
}
throw error;
}
}
private async createContainer(
containerRecord: UserContainerRecord,
token: string,
gatewayPort: number
): Promise<ContainerHandle> {
const container = await this.docker.createContainer({
name: containerRecord.containerName,
Image: this.getOpenClawImage(),
Env: [
`MOSAIC_API_URL=${this.getMosaicApiUrl()}`,
`AGENT_TOKEN=${token}`,
`AGENT_ID=${containerRecord.id}`,
],
ExposedPorts: {
[OPENCLAW_GATEWAY_PORT_KEY]: {},
},
HostConfig: {
Binds: [`${this.getVolumeName(containerRecord.userId)}:${OPENCLAW_STATE_PATH}`],
PortBindings: {
[OPENCLAW_GATEWAY_PORT_KEY]: [{ HostPort: String(gatewayPort) }],
},
NetworkMode: this.getOpenClawNetwork(),
},
});
return container as unknown as ContainerHandle;
}
private extractGatewayPort(inspect: DockerInspect): number | null {
const networkPort = inspect.NetworkSettings?.Ports?.[OPENCLAW_GATEWAY_PORT_KEY]?.[0]?.HostPort;
if (networkPort) {
return this.parseInteger(networkPort, 0) || null;
}
const hostPort = inspect.HostConfig?.PortBindings?.[OPENCLAW_GATEWAY_PORT_KEY]?.[0]?.HostPort;
if (hostPort) {
return this.parseInteger(hostPort, 0) || null;
}
return null;
}
private async findAvailableGatewayPort(): Promise<number> {
const usedPorts = await this.prisma.userContainer.findMany({
where: {
gatewayPort: { not: null },
},
select: {
gatewayPort: true,
},
});
const takenPorts = new Set<number>();
for (const entry of usedPorts) {
if (entry.gatewayPort !== null) {
takenPorts.add(entry.gatewayPort);
}
}
let candidate = this.getPortRangeStart();
while (takenPorts.has(candidate)) {
candidate += 1;
}
return candidate;
}
private getGatewayToken(storedToken: string): string {
if (this.crypto.isEncrypted(storedToken)) {
return this.crypto.decrypt(storedToken);
}
return storedToken;
}
private parseInteger(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
private isDockerNotFound(error: unknown): boolean {
return this.getDockerStatusCode(error) === 404;
}
private isAlreadyStopped(error: unknown): boolean {
return this.getDockerStatusCode(error) === 304;
}
private getDockerStatusCode(error: unknown): number | null {
if (typeof error !== "object" || error === null || !("statusCode" in error)) {
return null;
}
const statusCode = error.statusCode;
return typeof statusCode === "number" ? statusCode : null;
}
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "Unknown error";
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
import { ContainerReaperService } from "./container-reaper.service";
@Module({
imports: [ScheduleModule, ContainerLifecycleModule],
providers: [ContainerReaperService],
})
export class ContainerReaperModule {}

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