Compare commits

..
Author SHA1 Message Date
jason.woltje 9eb48e1d9b feat(Phase 4): Memory & Intelligence — memory, log, summarization, skills (#91)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:56:50 +00:00
jason.woltje d83ebe65e9 verify(P3-008): Phase 3 web dashboard verification (#90)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:37:43 +00:00
jason.woltje 4fe7d09e5c feat(web): admin panel with session management (#89)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:33:45 +00:00
jason.woltje e44cb7e56a feat(web): settings page with profile, providers, and models (#88)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:31:51 +00:00
jason.woltje fd4b7c2ba2 feat(web): project list and mission dashboard views (#87)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:30:11 +00:00
jason.woltje a1a1976b38 feat(web): task management with list view and kanban board (#86)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:28:17 +00:00
jason.woltje f0d1d4bafa feat(web): chat UI with conversations and WebSocket streaming (#84)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:25:28 +00:00
jason.woltje 600da70960 feat(web): wire auth pages with BetterAuth and route guards (#83)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:21:33 +00:00
jason.woltje 780f85e0d6 feat(web): scaffold Next.js 16 dashboard with design system and auth client (#82)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:18:09 +00:00
jason.woltjeandClaude Opus 4.6 5d936d58a0 fix: add missing @Inject() decorators and fix coord workspace root detection
- Add @Inject() to all gateway constructor params (required without emitDecoratorMetadata)
  - AgentService: ProviderService, CoordService
  - RoutingService: ProviderService
  - ProvidersController: ProviderService, RoutingService
  - SessionsController: AgentService
- Fix coord controller ALLOWED_ROOTS to walk up to monorepo root (pnpm-workspace.yaml)
- Gateway now boots and serves all routes correctly

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-13 08:00:57 -05:00
jason.woltje 8da2759fec fix: coord review remediations (path traversal, JSON parse, race condition) (#81)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:43:49 +00:00
jason.woltjeandClaude Opus 4.6 b03c603759 docs: mark Phase 2 complete — update manifest and scratchpad
Phase 0-2 all done. P2-007 closed via PR #79. Mission advances to Phase 3.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 22:40:59 -05:00
jason.woltje 77da12a5ee test: verify Phase 2 — routing + coord tests (P2-007) (#79)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:40:07 +00:00
jason.woltje 7f6dc43a2d feat: agent session management — metrics, channels, dispose (P2-006) (#78)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:35:59 +00:00
jason.woltje f3a7eadcea feat: @mosaic/coord — migrate from v0, gateway integration (P2-005) (#77)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:32:20 +00:00
jason.woltje 7f6815feaf feat: tool registration — brain tools for agent sessions (P2-004) (#76)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:18:45 +00:00
jason.woltje 7485f32e69 feat: agent routing engine — cost/capability matrix (P2-003) (#75)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:13:50 +00:00
jason.woltje 95f95f54cf feat: multi-provider support — Anthropic + Ollama (P2-002) (#74)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:10:51 +00:00
jason.woltje aa9ee75a2a fix: auth handler + circular imports — Phase 1 verification (P1-009) (#73)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:02:02 +00:00
jason.woltje c54b69f7ce feat: gateway CRUD routes — conversations, projects, missions, tasks (P1-005/006) (#72)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:41:03 +00:00
jason.woltje 38897fe423 feat: auth middleware, brain data layer, Valkey queue (P1-002/003/004) (#71)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:37:56 +00:00
jason.woltje cbac5902db fix: Phase 0 verification — CI gates green (P0-009) (#70)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:28:38 +00:00
jason.woltje 4cd5cbf893 feat: Woodpecker CI pipeline + project docs (P0-007, P0-008) (#69)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:25:31 +00:00
jason.woltje 6e3cccc812 feat(auth): @mosaic/auth — BetterAuth email/password setup (#68)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:21:15 +00:00
jason.woltje 2b1723e898 feat(db): @mosaic/db — Drizzle schema, PG connection, migrations (#67)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:17:18 +00:00
jason.woltje 573484c83e fix: Jaeger image tag + remap PG/Valkey ports (#66)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:06:00 +00:00
jason.woltjeandClaude Opus 4.6 bf2cb3a271 docs: mark P0-002, P0-005, P0-006 done (PR #65)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:56:16 -05:00
jason.woltje 35e4e2e527 feat: foundation — Docker Compose, OTEL, shared types (#65)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:55:33 +00:00
jason.woltjeandClaude Opus 4.6 f6f05cf23a docs: add FIX-03 agent session sandboxing task (#64)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:49:05 -05:00
jason.woltjeandClaude Opus 4.6 15352448d5 fix: switch gateway to ESM + explicit @Inject for tsx compatibility
Pi SDK is ESM-only. tsx (esbuild) doesn't emit decorator metadata,
so NestJS constructor injection fails without explicit @Inject().

- Set "type": "module" in gateway package.json
- Switch tsconfig to NodeNext module resolution
- Add @Inject(AgentService) to ChatController and ChatGateway

Tested end-to-end: REST /api/chat → Pi SDK → Anthropic → response OK.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:43:08 -05:00
jason.woltjeandClaude Opus 4.6 c58003e5e3 docs: add gatekeeper review follow-up tasks (FIX-01, FIX-02)
- #62: piSession.dispose() missing in destroySession
- #63: React anti-pattern in TUI agent:end handler

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:36:17 -05:00
jason.woltjeandClaude Opus 4.6 04528459ec docs: update TASKS.md and scratchpad for communication spine completion
Mark P1-001, P1-007, P1-008, P2-001, P5-002, P6-005 as done (PR #61).
Add session 2 log entry for vertical slice delivery.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:34:26 -05:00
jason.woltje 4f84a01072 feat: communication spine — gateway, TUI, Discord (#61)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:33:32 +00:00
jason.woltjeandClaude Opus 4.6 888bc32be1 docs: mark P0-001 scaffold monorepo as done
PR #60 merged, issue #1 closed.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:12:44 -05:00
jason.woltje 6d0d288e31 feat(P0-001): scaffold monorepo structure (#60)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:11:46 +00:00
jason.woltjeandClaude Opus 4.6 339641352e docs: record vertical slice reorder in scratchpad
Jason directed: build Pi TUI → Gateway → Discord communication
spine before backfilling horizontal layers.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:06:17 -05:00
jason.woltjeandClaude Opus 4.6 e7f338e3a9 chore: planning gate — milestones, issues, and task breakdown
Break PRD into 8 milestones (Phase 0–7) with 59 issues on Gitea.
Populate TASKS.md, update mission manifest, initialize scratchpad.
Repo created at git.mosaicstack.dev/mosaic/mosaic-stack.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 19:51:51 -05:00
1958 changed files with 17040 additions and 348551 deletions
-58
View File
@@ -1,58 +0,0 @@
# Dependencies (installed fresh in Docker)
node_modules
**/node_modules
# Build outputs (built fresh in Docker)
dist
**/dist
.next
**/.next
# TurboRepo cache
.turbo
**/.turbo
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment files
.env
.env.*
!.env.example
# Credentials
.admin-credentials
# Testing
coverage
**/coverage
# Logs
*.log
# Misc
*.tsbuildinfo
**/*.tsbuildinfo
.pnpm-approve-builds
.husky/_
# Git
.git
.gitignore
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# Documentation (not needed in container)
docs
*.md
!README.md
+15 -496
View File
@@ -1,501 +1,20 @@
# ==============================================
# Mosaic Stack Environment Configuration
# ==============================================
# Copy this file to .env and customize for your environment
# Database (port 5433 avoids conflict with host PostgreSQL)
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# ======================
# Application Ports
# ======================
API_PORT=3001
API_HOST=0.0.0.0
WEB_PORT=3000
# Valkey (Redis-compatible, port 6380 avoids conflict with host Redis/Valkey)
VALKEY_URL=redis://localhost:6380
# ======================
# Web Configuration
# ======================
NEXT_PUBLIC_APP_URL=http://localhost:3000
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
# Docker Compose host port overrides (optional)
# PG_HOST_PORT=5433
# VALKEY_HOST_PORT=6380
# ======================
# PostgreSQL Database
# ======================
# Bundled PostgreSQL
# SECURITY: Change POSTGRES_PASSWORD to a strong random password in production
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
# OpenTelemetry
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_SERVICE_NAME=mosaic-gateway
# External PostgreSQL (managed service)
# To use an external instance, update DATABASE_URL above
# Example: DATABASE_URL=postgresql://user:[email protected]:5432/mosaic
# Auth (BetterAuth)
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
BETTER_AUTH_URL=http://localhost:4000
# PostgreSQL Performance Tuning (Optional)
POSTGRES_SHARED_BUFFERS=256MB
POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
POSTGRES_MAX_CONNECTIONS=100
# ======================
# Valkey Cache (Redis-compatible)
# ======================
# Bundled Valkey
VALKEY_URL=redis://valkey:6379
VALKEY_HOST=valkey
VALKEY_PORT=6379
# VALKEY_PASSWORD= # Optional: Password for Valkey authentication
VALKEY_MAXMEMORY=256mb
# External Redis/Valkey (managed service)
# To use an external instance, update VALKEY_URL above
# Example: VALKEY_URL=redis://elasticache.amazonaws.com:6379
# Example with auth: VALKEY_URL=redis://:[email protected]:6379
# Knowledge Module Cache Configuration
# Set KNOWLEDGE_CACHE_ENABLED=false to disable caching (useful for development)
KNOWLEDGE_CACHE_ENABLED=true
# Cache TTL in seconds (default: 300 = 5 minutes)
KNOWLEDGE_CACHE_TTL=300
# ======================
# Authentication (Authentik OIDC)
# ======================
# Set to 'true' to enable OIDC authentication with Authentik
# When enabled, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI are required
OIDC_ENABLED=false
# Authentik Server URLs (required when OIDC_ENABLED=true)
# OIDC_ISSUER must end with a trailing slash (/)
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
# Redirect URI must match what's configured in Authentik
# Development: http://localhost:3001/auth/oauth2/callback/authentik
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# Authentik PostgreSQL Database
AUTHENTIK_POSTGRES_USER=authentik
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_POSTGRES_DB=authentik
# Authentik Configuration
# CRITICAL: Generate a random secret key with at least 50 characters
# Example: openssl rand -base64 50
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
AUTHENTIK_ERROR_REPORTING=false
# SECURITY: Change bootstrap password immediately after first login
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
AUTHENTIK_COOKIE_DOMAIN=.localhost
# Authentik Ports
AUTHENTIK_PORT_HTTP=9000
AUTHENTIK_PORT_HTTPS=9443
# ======================
# CSRF Protection
# ======================
# CRITICAL: Generate a random secret for CSRF token signing
# Required in production; auto-generated in development (not persistent across restarts)
# Command to generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
CSRF_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING
# ======================
# JWT Configuration
# ======================
# CRITICAL: Generate a random secret key with at least 32 characters
# Example: openssl rand -base64 32
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
JWT_EXPIRATION=24h
# ======================
# BetterAuth Configuration
# ======================
# CRITICAL: Generate a random secret key with at least 32 characters
# This is used by BetterAuth for session management and CSRF protection
# Example: openssl rand -base64 32
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
# Optional explicit BetterAuth origin for callback/error URL generation.
# When empty, backend falls back to NEXT_PUBLIC_API_URL.
BETTER_AUTH_URL=
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
TRUSTED_ORIGINS=
# Cookie Domain (for cross-subdomain session sharing)
# Leave empty for single-domain setups. Set to ".example.com" for cross-subdomain.
COOKIE_DOMAIN=
# ======================
# Encryption (Credential Security)
# ======================
# CRITICAL: Generate a random 32-byte (256-bit) encryption key
# This key is used for AES-256-GCM encryption of OAuth tokens and sensitive data
# Command to generate: openssl rand -hex 32
# SECURITY: Never commit this key to version control
# SECURITY: Use different keys for development, staging, and production
# SECURITY: Store production keys in a secure secrets manager (see docs/design/credential-security.md)
ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_GENERATE_WITH_OPENSSL_RAND_HEX_32
# ======================
# OpenBao Secrets Management
# ======================
# OpenBao provides Transit encryption for sensitive credentials
# Enable with: COMPOSE_PROFILES=openbao or COMPOSE_PROFILES=full
# Auto-initialized on first run via openbao-init sidecar
# Bundled OpenBao (when openbao profile enabled)
OPENBAO_ADDR=http://openbao:8200
OPENBAO_PORT=8200
# External OpenBao/Vault (managed service)
# Disable 'openbao' profile and set OPENBAO_ADDR to your external instance
# Example: OPENBAO_ADDR=https://vault.example.com:8200
# Example: OPENBAO_ADDR=https://vault.hashicorp.com:8200
# AppRole Authentication (Optional)
# If not set, credentials are read from /openbao/init/approle-credentials volume
# Required when using external OpenBao
# OPENBAO_ROLE_ID=your-role-id-here
# OPENBAO_SECRET_ID=your-secret-id-here
# Fallback Mode
# When OpenBao is unavailable, API automatically falls back to AES-256-GCM
# encryption using ENCRYPTION_KEY. This provides graceful degradation.
# ======================
# Ollama (Optional AI Service)
# ======================
# Set OLLAMA_ENDPOINT to use local or remote Ollama
# For bundled Docker service: http://ollama:11434
# For external service: http://your-ollama-server:11434
OLLAMA_ENDPOINT=http://ollama:11434
OLLAMA_PORT=11434
# Embedding Model Configuration
# Model used for generating knowledge entry embeddings
# Default: mxbai-embed-large (1024-dim, padded to 1536)
# Alternative: nomic-embed-text (768-dim, padded to 1536)
# Note: Embeddings are padded/truncated to 1536 dimensions to match schema
OLLAMA_EMBEDDING_MODEL=mxbai-embed-large
# Semantic Search Configuration
# Similarity threshold for semantic search (0.0 to 1.0, where 1.0 is identical)
# Lower values return more results but may be less relevant
# Default: 0.5 (50% similarity)
SEMANTIC_SEARCH_SIMILARITY_THRESHOLD=0.5
# ======================
# OpenAI API (For Semantic Search)
# ======================
# OPTIONAL: Semantic search requires an OpenAI API key
# Get your API key from: https://platform.openai.com/api-keys
# If not configured, semantic search endpoints will return an error
# OPENAI_API_KEY=sk-...
# ======================
# Application Environment
# ======================
NODE_ENV=development
# ======================
# Docker Image Configuration
# ======================
# Docker image tag for pulling pre-built images from git.mosaicstack.dev registry
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
# For local builds, use docker-compose.build.yml instead
# Options:
# - latest: Pull latest images from registry (default, built from main branch)
# - <version>: Use specific version tag (e.g., v1.0.0)
IMAGE_TAG=latest
# ======================
# Docker Compose Profiles
# ======================
# Enable optional services via profiles. Combine multiple profiles with commas.
#
# Available profiles:
# - database: PostgreSQL database (disable to use external database)
# - cache: Valkey cache (disable to use external Redis)
# - openbao: OpenBao secrets management (disable to use external vault or fallback encryption)
# - authentik: Authentik OIDC authentication (disable to use external auth provider)
# - ollama: Ollama AI/LLM service (disable to use external LLM service)
# - traefik-bundled: Bundled Traefik reverse proxy (disable to use external proxy)
# - full: Enable all optional services (turnkey deployment)
#
# Examples:
# COMPOSE_PROFILES=full # Everything bundled (development)
# COMPOSE_PROFILES=database,cache,openbao # Core services only
# COMPOSE_PROFILES= # All external services (production)
COMPOSE_PROFILES=full
# ======================
# Traefik Reverse Proxy
# ======================
# TRAEFIK_MODE options:
# - bundled: Use bundled Traefik (requires traefik-bundled profile)
# - upstream: Connect to external Traefik instance
# - none: Direct port exposure without reverse proxy (default)
TRAEFIK_MODE=none
# Domain configuration for Traefik routing
MOSAIC_API_DOMAIN=api.mosaic.local
MOSAIC_WEB_DOMAIN=mosaic.local
MOSAIC_AUTH_DOMAIN=auth.mosaic.local
# External Traefik network name (for upstream mode and swarm)
# Must match the network name of your existing Traefik instance
TRAEFIK_NETWORK=traefik-public
TRAEFIK_DOCKER_NETWORK=traefik-public
# TLS/SSL Configuration
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):
TRAEFIK_ACME_EMAIL=[email protected]
# For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty
# Traefik Dashboard (bundled mode only)
TRAEFIK_DASHBOARD_ENABLED=true
TRAEFIK_DASHBOARD_PORT=8080
# ======================
# Gitea Integration (Coordinator)
# ======================
# Gitea instance URL
GITEA_URL=https://git.mosaicstack.dev
# Coordinator bot credentials (see docs/1-getting-started/3-configuration/4-gitea-coordinator.md)
# SECURITY: Store GITEA_BOT_TOKEN in secrets vault, not in version control
GITEA_BOT_USERNAME=mosaic
GITEA_BOT_TOKEN=REPLACE_WITH_COORDINATOR_BOT_API_TOKEN
GITEA_BOT_PASSWORD=REPLACE_WITH_COORDINATOR_BOT_PASSWORD
# Repository configuration
GITEA_REPO_OWNER=mosaic
GITEA_REPO_NAME=stack
# Webhook secret for coordinator (HMAC SHA256 signature verification)
# SECURITY: Generate random secret with: openssl rand -hex 32
# Configure in Gitea: Repository Settings → Webhooks → Add Webhook
GITEA_WEBHOOK_SECRET=REPLACE_WITH_RANDOM_WEBHOOK_SECRET
# Coordinator API Key (service-to-service authentication)
# CRITICAL: Generate a random API key with at least 32 characters
# Example: openssl rand -base64 32
# The coordinator service uses this key to authenticate with the API
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 prevents DoS attacks on webhook and API endpoints
# TTL is in seconds, limits are per TTL window
# Global rate limit (applies to all endpoints unless overridden)
# Time window in seconds
RATE_LIMIT_TTL=60
# Requests per window
RATE_LIMIT_GLOBAL_LIMIT=100
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
RATE_LIMIT_WEBHOOK_LIMIT=60
# Coordinator endpoints (/coordinator/*) — requests per minute
RATE_LIMIT_COORDINATOR_LIMIT=100
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
RATE_LIMIT_HEALTH_LIMIT=300
# Storage backend for rate limiting (redis or memory)
# redis: Uses Valkey for distributed rate limiting (recommended for production)
# memory: Uses in-memory storage (single instance only, for development)
RATE_LIMIT_STORAGE=redis
# ======================
# Discord Bridge (Optional)
# ======================
# Discord bot integration for chat-based control
# Get bot token from: https://discord.com/developers/applications
# DISCORD_BOT_TOKEN=your-discord-bot-token-here
# DISCORD_GUILD_ID=your-discord-server-id
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
# DISCORD_WORKSPACE_ID=your-workspace-uuid
#
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Discord commands will execute within this workspace context for proper
# multi-tenant isolation. Each Discord bot instance should be configured for
# a single workspace.
# ======================
# Matrix Bridge (Optional)
# ======================
# Matrix bot integration for chat-based control via Matrix protocol
# Requires a Matrix account with an access token for the bot user
# Set these AFTER deploying Synapse and creating the bot account.
#
# SECURITY: MATRIX_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Matrix commands will execute within this workspace context for proper
# multi-tenant isolation. Each Matrix bot instance should be configured for
# a single workspace.
MATRIX_HOMESERVER_URL=http://synapse:8008
MATRIX_ACCESS_TOKEN=
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
MATRIX_SERVER_NAME=matrix.woltje.com
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
# MATRIX_WORKSPACE_ID=your-workspace-uuid
# ======================
# Matrix / Synapse Deployment
# ======================
# Domains for Traefik routing to Matrix services
MATRIX_DOMAIN=matrix.woltje.com
ELEMENT_DOMAIN=chat.woltje.com
# Synapse database (created automatically by synapse-db-init in the swarm compose)
SYNAPSE_POSTGRES_DB=synapse
SYNAPSE_POSTGRES_USER=synapse
SYNAPSE_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_SYNAPSE_DB_PASSWORD
# Image tags for Matrix services
SYNAPSE_IMAGE_TAG=latest
ELEMENT_IMAGE_TAG=latest
# ======================
# Orchestrator Configuration
# ======================
# API Key for orchestrator agent management endpoints
# CRITICAL: Generate a random API key with at least 32 characters
# Example: openssl rand -base64 32
# Required for all /agents/* endpoints (spawn, kill, kill-all, status)
# Health endpoints (/health/*) remain unauthenticated
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
# ======================
# Choose the AI provider for orchestrator agents
# Options: ollama, claude, openai
# Default: ollama (no API key required)
AI_PROVIDER=ollama
# Ollama Configuration (when AI_PROVIDER=ollama)
# For local Ollama: http://localhost:11434
# For remote Ollama: http://your-ollama-server:11434
OLLAMA_MODEL=llama3.1:latest
# Claude API Key
# Required only when AI_PROVIDER=claude.
# Get your API key from: https://console.anthropic.com/
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY
# OpenAI API Configuration (when AI_PROVIDER=openai)
# OPTIONAL: Only required if AI_PROVIDER=openai
# Get your API key from: https://platform.openai.com/api-keys
# OPENAI_API_KEY=sk-...
# ======================
# Speech Services (STT / TTS)
# ======================
# Speech-to-Text (STT) - Whisper via Speaches
# Set STT_ENABLED=true to enable speech-to-text transcription
# STT_BASE_URL is required when STT_ENABLED=true
STT_ENABLED=true
STT_BASE_URL=http://speaches:8000/v1
STT_MODEL=Systran/faster-whisper-large-v3-turbo
STT_LANGUAGE=en
# Text-to-Speech (TTS) - Default Engine (Kokoro)
# Set TTS_ENABLED=true to enable text-to-speech synthesis
# TTS_DEFAULT_URL is required when TTS_ENABLED=true
TTS_ENABLED=true
TTS_DEFAULT_URL=http://kokoro-tts:8880/v1
TTS_DEFAULT_VOICE=af_heart
TTS_DEFAULT_FORMAT=mp3
# Text-to-Speech (TTS) - Premium Engine (Chatterbox) - Optional
# Higher quality voice cloning engine, disabled by default
# TTS_PREMIUM_URL is required when TTS_PREMIUM_ENABLED=true
TTS_PREMIUM_ENABLED=false
TTS_PREMIUM_URL=http://chatterbox-tts:8881/v1
# Text-to-Speech (TTS) - Fallback Engine (Piper/OpenedAI) - Optional
# Lightweight fallback engine, disabled by default
# TTS_FALLBACK_URL is required when TTS_FALLBACK_ENABLED=true
TTS_FALLBACK_ENABLED=false
TTS_FALLBACK_URL=http://openedai-speech:8000/v1
# Whisper model for Speaches STT engine
SPEACHES_WHISPER_MODEL=Systran/faster-whisper-large-v3-turbo
# Speech Service Limits
# Maximum upload file size in bytes (default: 25MB)
SPEECH_MAX_UPLOAD_SIZE=25000000
# Maximum audio duration in seconds (default: 600 = 10 minutes)
SPEECH_MAX_DURATION_SECONDS=600
# Maximum text length for TTS in characters (default: 4096)
SPEECH_MAX_TEXT_LENGTH=4096
# ======================
# Mosaic Telemetry (Task Completion Tracking & Predictions)
# ======================
# Telemetry tracks task completion patterns to provide time estimates and predictions.
# Data is sent to the Mosaic Telemetry API (a separate service).
# Master switch: set to false to completely disable telemetry (no HTTP calls will be made)
MOSAIC_TELEMETRY_ENABLED=true
# URL of the telemetry API server
# For Docker Compose (internal): http://telemetry-api:8000
# For production/swarm: https://tel-api.mosaicstack.dev
MOSAIC_TELEMETRY_SERVER_URL=http://telemetry-api:8000
# API key for authenticating with the telemetry server
# Generate with: openssl rand -hex 32
MOSAIC_TELEMETRY_API_KEY=your-64-char-hex-api-key-here
# Unique identifier for this Mosaic Stack instance
# Generate with: uuidgen or python -c "import uuid; print(uuid.uuid4())"
MOSAIC_TELEMETRY_INSTANCE_ID=your-instance-uuid-here
# Dry run mode: set to true to log telemetry events to console instead of sending HTTP requests
# Useful for development and debugging telemetry payloads
MOSAIC_TELEMETRY_DRY_RUN=false
# ======================
# Logging & Debugging
# ======================
LOG_LEVEL=info
DEBUG=false
# Gateway
GATEWAY_PORT=4000
-85
View File
@@ -1,85 +0,0 @@
# Traefik Bundled Mode Configuration
# Copy this to .env to enable bundled Traefik reverse proxy
#
# Usage:
# cp .env.traefik-bundled.example .env
# docker compose --profile traefik-bundled up -d
# ======================
# Traefik Configuration
# ======================
TRAEFIK_MODE=bundled
TRAEFIK_ENABLE=true
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_DOCKER_NETWORK=mosaic-public
# Domain configuration
MOSAIC_API_DOMAIN=api.mosaic.local
MOSAIC_WEB_DOMAIN=mosaic.local
MOSAIC_AUTH_DOMAIN=auth.mosaic.local
# TLS/SSL Configuration
TRAEFIK_TLS_ENABLED=true
# For Let's Encrypt (production):
# [email protected]
# TRAEFIK_CERTRESOLVER=letsencrypt
# For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty
TRAEFIK_ACME_EMAIL=
# Traefik Dashboard
TRAEFIK_DASHBOARD_ENABLED=true
TRAEFIK_DASHBOARD_PORT=8080
# Traefik Ports
TRAEFIK_HTTP_PORT=80
TRAEFIK_HTTPS_PORT=443
# ======================
# Application Ports (not exposed when using Traefik)
# ======================
API_PORT=3001
WEB_PORT=3000
# ======================
# PostgreSQL Database
# ======================
POSTGRES_USER=mosaic
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
POSTGRES_DB=mosaic
POSTGRES_PORT=5432
# ======================
# Valkey Cache
# ======================
VALKEY_PORT=6379
VALKEY_MAXMEMORY=256mb
# ======================
# Authentication (Authentik OIDC)
# ======================
OIDC_ISSUER=https://auth.mosaic.local/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
OIDC_REDIRECT_URI=https://api.mosaic.local/auth/callback
# Authentik Configuration
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
AUTHENTIK_COOKIE_DOMAIN=.mosaic.local
AUTHENTIK_POSTGRES_USER=authentik
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_POSTGRES_DB=authentik
# ======================
# JWT Configuration
# ======================
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
JWT_EXPIRATION=24h
# ======================
# Docker Compose Profiles
# ======================
# Enable bundled Traefik and optional services
COMPOSE_PROFILES=traefik-bundled,authentik
-83
View File
@@ -1,83 +0,0 @@
# Traefik Upstream Mode Configuration
# Connect to an existing external Traefik instance
#
# Prerequisites:
# 1. External Traefik instance must be running
# 2. External network must exist: docker network create traefik-public
# 3. Copy docker-compose.override.yml.example to docker-compose.override.yml
# 4. Uncomment upstream mode network configuration in override file
#
# Usage:
# cp .env.traefik-upstream.example .env
# docker compose up -d
# ======================
# Traefik Configuration
# ======================
TRAEFIK_MODE=upstream
TRAEFIK_ENABLE=true
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_DOCKER_NETWORK=traefik-public
TRAEFIK_NETWORK=traefik-public
# Domain configuration
# These domains must be configured in your DNS or /etc/hosts
MOSAIC_API_DOMAIN=api.mosaic.uscllc.com
MOSAIC_WEB_DOMAIN=mosaic.uscllc.com
MOSAIC_AUTH_DOMAIN=auth.mosaic.uscllc.com
# TLS/SSL Configuration
TRAEFIK_TLS_ENABLED=true
# ACME/Certresolver managed by upstream Traefik
TRAEFIK_CERTRESOLVER=
# ======================
# Application Ports (not exposed when using Traefik)
# ======================
# These ports are only used internally within Docker network
API_PORT=3001
WEB_PORT=3000
# ======================
# PostgreSQL Database
# ======================
POSTGRES_USER=mosaic
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
POSTGRES_DB=mosaic
POSTGRES_PORT=5432
# ======================
# Valkey Cache
# ======================
VALKEY_PORT=6379
VALKEY_MAXMEMORY=256mb
# ======================
# Authentication (Authentik OIDC)
# ======================
OIDC_ISSUER=https://auth.mosaic.uscllc.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
OIDC_REDIRECT_URI=https://api.mosaic.uscllc.com/auth/callback
# Authentik Configuration
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
AUTHENTIK_COOKIE_DOMAIN=.mosaic.uscllc.com
AUTHENTIK_POSTGRES_USER=authentik
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_POSTGRES_DB=authentik
# ======================
# JWT Configuration
# ======================
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
JWT_EXPIRATION=24h
# ======================
# Docker Compose Profiles
# ======================
# Enable optional services (do NOT enable traefik-bundled in upstream mode)
COMPOSE_PROFILES=authentik
+5 -65
View File
@@ -1,71 +1,11 @@
# Dependencies
logs/
node_modules
.pnpm-store
# Build outputs
dist
.next
.turbo
# Compiled source (prevent accidental commits)
apps/*/src/**/*.js
apps/*/src/**/*.d.ts
apps/*/src/**/*.js.map
apps/*/src/**/*.d.ts.map
packages/*/src/**/*.js
packages/*/src/**/*.d.ts
packages/*/src/**/*.js.map
packages/*/src/**/*.d.ts.map
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.next
coverage
.env
.env.local
.env.test
.env.development.local
.env.test.local
.env.production.local
.env.bak.*
*.bak
# Credentials (never commit)
.admin-credentials
# Testing
coverage
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
*.tsbuildinfo
.pnpm-approve-builds
# Husky
.husky/_
# Orchestrator reports (generated by QA automation, cleaned up after processing)
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
.pnpm-store
docs/reports/
+3 -1
View File
@@ -1,2 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
npx git-secrets --scan || echo "Warning: git-secrets not installed"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm typecheck && pnpm lint && pnpm format:check
+9
View File
@@ -0,0 +1,9 @@
{
"**/*.{ts,tsx,js,jsx}": [
"prettier --write",
"eslint --fix"
],
"**/*.{json,md,yaml,yml}": [
"prettier --write"
]
}
-48
View File
@@ -1,48 +0,0 @@
// Monorepo-aware lint-staged configuration
// STRICT ENFORCEMENT ENABLED: Blocks commits if affected packages have violations
//
// IMPORTANT: This lints ENTIRE packages, not just changed files.
// If you touch ANY file in a package with violations, you must fix the whole package.
// This forces incremental cleanup - work in a package = clean up that package.
//
export default {
// TypeScript files - lint and typecheck affected packages
'**/*.{ts,tsx}': (filenames) => {
const commands = [];
// 1. Format first (auto-fixes what it can)
commands.push(`prettier --write ${filenames.join(' ')}`);
// 2. Extract affected packages from absolute paths
// lint-staged passes absolute paths, so we need to extract the relative part
const packages = [...new Set(filenames.map(f => {
// Match either absolute or relative paths: .../packages/shared/... or packages/shared/...
const match = f.match(/(?:^|\/)(apps|packages)\/([^/]+)\//);
if (!match) return null;
// Return package name format for turbo (e.g., "@mosaic/api")
return `@mosaic/${match[2]}`;
}))].filter(Boolean);
if (packages.length === 0) {
return commands;
}
// 3. Lint entire affected packages via turbo
// --max-warnings=0 means ANY warning/error blocks the commit
packages.forEach(pkg => {
commands.push(`pnpm turbo run lint --filter=${pkg} -- --max-warnings=0`);
});
// 4. Type-check affected packages
packages.forEach(pkg => {
commands.push(`pnpm turbo run typecheck --filter=${pkg}`);
});
return commands;
},
// Format all other files
'**/*.{js,jsx,json,md,yml,yaml}': [
'prettier --write',
],
};
+63
View File
@@ -13,3 +13,66 @@ This repository is attached to the machine-wide Mosaic framework.
- Keep universal standards in `~/.config/mosaic`
- Keep repo-specific behavior in this repo
- Avoid copying large runtime configs into each project
## Optional Quality Rails
Use `.mosaic/quality-rails.yml` to track whether quality rails are enabled for this repo.
Apply a template:
```bash
~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target .
```
Verify enforcement:
```bash
~/.config/mosaic/bin/mosaic-quality-verify --target .
```
## Optional Matrix Orchestrator Rail
Repo-local orchestrator state lives in `.mosaic/orchestrator/`.
Run one cycle:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-matrix-cycle
~/.config/mosaic/bin/mosaic-orchestrator-run --once
```
Run continuously:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-run --poll-sec 10
```
Bridge events to Matrix:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-matrix-publish
~/.config/mosaic/bin/mosaic-orchestrator-matrix-consume
```
Run until queue is drained (syncs from `docs/tasks.md` first):
```bash
~/.config/mosaic/bin/mosaic-orchestrator-drain
```
Set worker command if auto-detect does not match your CLI:
```bash
export MOSAIC_WORKER_EXEC="codex -p"
# or
export MOSAIC_WORKER_EXEC="opencode -p"
```
Use repo helper (foreground or detached):
```bash
bash scripts/agent/orchestrator-daemon.sh drain
bash scripts/agent/orchestrator-daemon.sh start
bash scripts/agent/orchestrator-daemon.sh status
bash scripts/agent/orchestrator-daemon.sh stop
```
+1 -1
View File
@@ -1,5 +1,5 @@
{
"enabled": true,
"enabled": false,
"transport": "matrix",
"matrix": {
"control_room_id": "",
+4
View File
@@ -0,0 +1,4 @@
{
"last_published_line": 0,
"since": null
}
+10 -86
View File
@@ -1,90 +1,14 @@
{
"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",
"mission_id": "mvp-20260312",
"name": "MVP",
"description": "",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"created_at": "2026-03-13T00:44:02Z",
"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": ""
}
]
"task_prefix": "",
"quality_gates": "",
"milestone_version": "0.0.1",
"milestones": [],
"sessions": []
}
+19
View File
@@ -0,0 +1,19 @@
{
"generated_at": "2026-03-13T00:44:51Z",
"runtime": "claude",
"mission_id": "mvp-20260312",
"mission_name": "MVP",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"quality_gates": "",
"current_milestone": {
"id": "",
"name": ""
},
"next_task": "",
"progress": {
"tasks_done": 0,
"tasks_total": 0,
"pct": 0
},
"current_branch": ""
}
+5 -5
View File
@@ -1,8 +1,8 @@
{
"session_id": "sess-002",
"runtime": "unknown",
"pid": 3178395,
"started_at": "2026-02-28T20:30:13Z",
"project_path": "/tmp/ms21-ui-001",
"session_id": "claude-20260312-194506-357136",
"runtime": "claude",
"pid": 357136,
"started_at": "2026-03-13T00:45:06Z",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"milestone_id": ""
}
+4
View File
@@ -0,0 +1,4 @@
{
"running_task_id": null,
"updated_at": null
}
+3
View File
@@ -0,0 +1,3 @@
{
"tasks": []
}
+1 -1
View File
@@ -7,4 +7,4 @@ template: ""
# - monorepo
#
# Apply manually:
# ~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target <repo>
# ~/.mosaic/bin/mosaic-quality-apply --template <template> --target <repo>
+13 -25
View File
@@ -1,29 +1,17 @@
#!/usr/bin/env bash
# Repo-specific hooks used by scripts/agent/*.sh for Mosaic Stack.
# Optional repo-specific hooks used by scripts/agent/*.sh
mosaic_hook_session_start() {
echo "[mosaic-stack] Branch: $(git rev-parse --abbrev-ref HEAD)"
echo "[mosaic-stack] Remotes:"
git remote -v | sed 's/^/[mosaic-stack] /'
if command -v node >/dev/null 2>&1; then
echo "[mosaic-stack] Node: $(node -v)"
fi
if command -v pnpm >/dev/null 2>&1; then
echo "[mosaic-stack] pnpm: $(pnpm -v)"
fi
}
# Called by session-start.sh
# mosaic_hook_session_start() {
# echo "Run repo-specific startup checks"
# }
mosaic_hook_critical() {
echo "[mosaic-stack] Recent commits:"
git log --oneline --decorate -n 5 | sed 's/^/[mosaic-stack] /'
echo "[mosaic-stack] Open TODO/FIXME markers (top 20):"
rg -n "(TODO|FIXME|HACK|SECURITY)" apps packages plugins docs --glob '!**/node_modules/**' -S \
| head -n 20 \
| sed 's/^/[mosaic-stack] /' \
|| true
}
# Called by critical.sh
# mosaic_hook_critical() {
# echo "Run repo-specific critical queries"
# }
mosaic_hook_session_end() {
echo "[mosaic-stack] Working tree summary:"
git status --short | sed 's/^/[mosaic-stack] /' || true
}
# Called by session-end.sh
# mosaic_hook_session_end() {
# echo "Run repo-specific end-of-session checks"
# }
+1 -1
View File
@@ -1 +1 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
-1
View File
@@ -1 +0,0 @@
24
+5 -5
View File
@@ -1,6 +1,6 @@
node_modules
dist
.next
.turbo
coverage
pnpm-lock.yaml
**/next-env.d.ts
**/dist
**/node_modules
**/drizzle
**/.next
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"printWidth": 100
}
-10
View File
@@ -1,10 +0,0 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
-36
View File
@@ -1,36 +0,0 @@
# Trivy CVE Suppressions — Upstream Dependencies
# Reviewed: 2026-02-13 | Milestone: M11-CIPipeline
#
# MITIGATED:
# - Go stdlib CVEs (6): gosu rebuilt from source with Go 1.26
# - npm bundled CVEs (5): npm removed from production Node.js images
# - Node.js 20 → 24 LTS migration (#367): base images updated
#
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar/minimatch (5 CVEs)
# Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6.
# === OpenBao false positives ===
# Trivy reads Go module pseudo-version (v0.0.0-20260204...) from bin/bao
# and reports CVEs fixed in openbao 2.0.32.4.4. We run openbao:2.5.0.
CVE-2024-8185 # HIGH: DoS via Raft join (fixed in 2.0.3)
CVE-2024-9180 # HIGH: privilege escalation (fixed in 2.0.3)
CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1)
CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4)
# === Next.js bundled tar/minimatch CVEs (upstream — waiting on Next.js release) ===
# Next.js 16.1.6 bundles [email protected] and [email protected] in next/dist/compiled/ (pre-compiled).
# These are NOT pnpm dependencies — they're embedded in the Next.js package itself.
# pnpm overrides cannot reach these; only a Next.js upgrade can fix them.
# Affects web image only (orchestrator and API are clean).
# npm was also removed from all production images, eliminating the npm-bundled copy.
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.8 and minimatch >= 10.2.1.
CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3)
CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4)
CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7)
CVE-2026-26960 # HIGH: tar arbitrary file read/write via malicious archive hardlink (needs tar >= 7.5.8)
CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs minimatch >= 10.2.1)
# === OpenBao Go stdlib (waiting on upstream rebuild) ===
# OpenBao 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.
CVE-2025-68121 # CRITICAL: crypto/tls session resumption
-141
View File
@@ -1,141 +0,0 @@
# Woodpecker CI Configuration for Mosaic Stack
## Pipeline Architecture
Split per-package pipelines with path filtering. Only affected packages rebuild on push.
```
.woodpecker/
├── api.yml # @mosaic/api (NestJS)
├── web.yml # @mosaic/web (Next.js)
├── orchestrator.yml # @mosaic/orchestrator (NestJS)
├── coordinator.yml # mosaic-coordinator (Python/FastAPI)
├── infra.yml # postgres + openbao Docker images
├── codex-review.yml # AI code/security review (PRs only)
├── README.md
└── schemas/
├── code-review-schema.json
└── security-review-schema.json
```
## Path Filtering
| Pipeline | Triggers On |
| ------------------ | --------------------------------------------------- |
| `api.yml` | `apps/api/**`, `packages/**`, root configs |
| `web.yml` | `apps/web/**`, `packages/**`, root configs |
| `orchestrator.yml` | `apps/orchestrator/**`, `packages/**`, root configs |
| `coordinator.yml` | `apps/coordinator/**` |
| `infra.yml` | `docker/**` |
| `codex-review.yml` | All PRs (no path filter) |
**Root configs** = `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `package.json`
## Security Chain
Every pipeline follows the full security chain required by the CI/CD guide:
```
source scanning (lint + pnpm audit / bandit + pip-audit)
-> docker build (Kaniko)
-> container scanning (Trivy: HIGH,CRITICAL)
-> package linking (Gitea registry)
```
Docker builds gate on ALL quality + security steps passing.
## Pipeline Dependency Graphs
### Node.js Apps (api, web, orchestrator)
```
install -> [security-audit, lint, prisma-generate*]
prisma-generate* -> [typecheck, prisma-migrate*]
prisma-migrate* -> test
[all quality gates] -> build -> docker-build -> trivy -> link
```
_\*prisma steps: api.yml only_
### Coordinator (Python)
```
install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
[all quality gates] -> docker-build -> trivy -> link
```
### Infrastructure
```
[docker-build-postgres, docker-build-openbao]
-> [trivy-postgres, trivy-openbao]
-> link
```
## Docker Images
| Image | Registry Path | Context |
| ------------------ | ----------------------------------------------- | ------------------- |
| stack-api | `git.mosaicstack.dev/mosaic/stack-api` | `.` (monorepo root) |
| stack-web | `git.mosaicstack.dev/mosaic/stack-web` | `.` (monorepo root) |
| stack-orchestrator | `git.mosaicstack.dev/mosaic/stack-orchestrator` | `.` (monorepo root) |
| stack-coordinator | `git.mosaicstack.dev/mosaic/stack-coordinator` | `apps/coordinator` |
| stack-postgres | `git.mosaicstack.dev/mosaic/stack-postgres` | `docker/postgres` |
| stack-openbao | `git.mosaicstack.dev/mosaic/stack-openbao` | `docker/openbao` |
## Image Tagging
| Condition | Tag | Purpose |
| ------------- | -------------------------- | -------------------------- |
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
| `main` branch | `latest` | Current latest build |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
## Required Secrets
Configure in Woodpecker UI (Settings > Secrets):
| Secret | Scope | Purpose |
| ---------------- | ----------------- | ------------------------------------------- |
| `gitea_username` | push, manual, tag | Gitea registry auth |
| `gitea_token` | push, manual, tag | Gitea registry auth (`package:write` scope) |
| `codex_api_key` | pull_request | Codex AI reviews |
## Codex AI Review Pipeline
The `codex-review.yml` pipeline runs independently on all PRs:
- **Code review**: Correctness, code quality, testing, performance
- **Security review**: OWASP Top 10, hardcoded secrets, injection flaws
Fails on blockers or critical/high severity security findings.
### Local Testing
```bash
~/.claude/scripts/codex/codex-code-review.sh --uncommitted
~/.claude/scripts/codex/codex-security-review.sh --uncommitted
```
## Troubleshooting
### "unauthorized: authentication required"
- Verify `gitea_username` and `gitea_token` secrets in Woodpecker
- Verify token has `package:write` scope
### Trivy scan fails with HIGH/CRITICAL
- Check if the vulnerability is in the base image (not our code)
- Add to `.trivyignore` if it's a known, accepted risk
- Use `--ignore-unfixed` (already set) to skip unfixable CVEs
### Package linking returns 404
- Normal for recently pushed packages — retry logic handles this
- If persistent: verify package name matches exactly (case-sensitive)
### Pipeline runs Docker builds on pull requests
- Docker build steps have `when: branch: [main]` guards
- PRs only run quality gates, not Docker builds
-232
View File
@@ -1,232 +0,0 @@
# 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
- &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:
# === 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
prisma-generate:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" prisma:generate
depends_on:
- install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/api
depends_on:
- prisma-generate
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/api
depends_on:
- prisma-generate
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' --exclude 'src/mosaic-telemetry/mosaic-telemetry.module.spec.ts'
depends_on:
- prisma-migrate
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
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"
fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
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="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
# === 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]
event: [push, manual, tag]
depends_on:
- security-trivy-api
+57
View File
@@ -0,0 +1,57 @@
variables:
- &node_image 'node:22-alpine'
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
when:
- event: [push, pull_request, manual]
steps:
install:
image: *node_image
commands:
- *install_deps
typecheck:
image: *node_image
commands:
- *install_deps
- pnpm typecheck
depends_on:
- install
lint:
image: *node_image
commands:
- *install_deps
- pnpm lint
depends_on:
- install
format:
image: *node_image
commands:
- *install_deps
- pnpm format:check
depends_on:
- install
test:
image: *node_image
commands:
- *install_deps
- pnpm test
depends_on:
- install
build:
image: *node_image
commands:
- *install_deps
- pnpm build
depends_on:
- typecheck
- lint
- format
- test
-90
View File
@@ -1,90 +0,0 @@
# Codex AI Review Pipeline for Woodpecker CI
# Drop this into your repo's .woodpecker/ directory to enable automated
# code and security reviews on every pull request.
#
# Required secrets:
# - codex_api_key: OpenAI API key or Codex-compatible key
#
# Optional secrets:
# - gitea_token: Gitea API token for posting PR comments (if not using tea CLI auth)
when:
event: pull_request
variables:
- &node_image "node:24-slim"
- &install_codex "npm i -g @openai/codex"
steps:
# --- Code Quality Review ---
code-review:
image: *node_image
environment:
CODEX_API_KEY:
from_secret: codex_api_key
commands:
- *install_codex
- apt-get update -qq && apt-get install -y -qq jq git > /dev/null 2>&1
# Generate the diff
- git fetch origin ${CI_COMMIT_TARGET_BRANCH:-main}
- DIFF=$(git diff origin/${CI_COMMIT_TARGET_BRANCH:-main}...HEAD)
# Run code review with structured output
- |
codex exec \
--sandbox read-only \
--output-schema .woodpecker/schemas/code-review-schema.json \
-o /tmp/code-review.json \
"You are an expert code reviewer. Review the following code changes for correctness, code quality, testing, performance, and documentation issues. Only flag actionable, important issues. Categorize as blocker/should-fix/suggestion. If code looks good, say so.
Changes:
$DIFF"
# Output summary
- echo "=== Code Review Results ==="
- jq '.' /tmp/code-review.json
- |
BLOCKERS=$(jq '.stats.blockers // 0' /tmp/code-review.json)
if [ "$BLOCKERS" -gt 0 ]; then
echo "FAIL: $BLOCKERS blocker(s) found"
exit 1
fi
echo "PASS: No blockers found"
# --- Security Review ---
security-review:
image: *node_image
environment:
CODEX_API_KEY:
from_secret: codex_api_key
commands:
- *install_codex
- apt-get update -qq && apt-get install -y -qq jq git > /dev/null 2>&1
# Generate the diff
- git fetch origin ${CI_COMMIT_TARGET_BRANCH:-main}
- DIFF=$(git diff origin/${CI_COMMIT_TARGET_BRANCH:-main}...HEAD)
# Run security review with structured output
- |
codex exec \
--sandbox read-only \
--output-schema .woodpecker/schemas/security-review-schema.json \
-o /tmp/security-review.json \
"You are an expert application security engineer. Review the following code changes for security vulnerabilities including OWASP Top 10, hardcoded secrets, injection flaws, auth/authz gaps, XSS, CSRF, SSRF, path traversal, and supply chain risks. Include CWE IDs and remediation steps. Only flag real security issues, not code quality.
Changes:
$DIFF"
# Output summary
- echo "=== Security Review Results ==="
- jq '.' /tmp/security-review.json
- |
CRITICAL=$(jq '.stats.critical // 0' /tmp/security-review.json)
HIGH=$(jq '.stats.high // 0' /tmp/security-review.json)
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
echo "FAIL: $CRITICAL critical, $HIGH high severity finding(s)"
exit 1
fi
echo "PASS: No critical or high severity findings"
-178
View File
@@ -1,178 +0,0 @@
# Coordinator Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for mosaic-coordinator (Python)
#
# Triggers on: apps/coordinator/**
# Security chain: bandit + pip-audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/coordinator/**"
- ".woodpecker/coordinator.yml"
variables:
- &python_image "python:3.11-slim"
- &activate_venv |
cd apps/coordinator
. venv/bin/activate
- &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: *python_image
commands:
- cd apps/coordinator
- python -m venv venv
- . venv/bin/activate
- pip install --no-cache-dir --upgrade "pip>=25.3"
- pip install --no-cache-dir --extra-index-url https://git.mosaicstack.dev/api/packages/mosaic/pypi/simple/ -e ".[dev]"
- pip install --no-cache-dir bandit pip-audit
ruff-check:
image: *python_image
commands:
- *activate_venv
- ruff check src/ tests/
depends_on:
- install
mypy:
image: *python_image
commands:
- *activate_venv
- mypy src/
depends_on:
- install
security-bandit:
image: *python_image
commands:
- *activate_venv
- bandit -r src/ -c bandit.yaml -f screen
depends_on:
- install
security-pip-audit:
image: *python_image
commands:
- *activate_venv
- pip-audit
depends_on:
- install
test:
image: *python_image
commands:
- *activate_venv
- pytest
depends_on:
- install
# === Docker Build & Push ===
docker-build-coordinator:
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-coordinator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
fi
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- ruff-check
- mypy
- security-bandit
- security-pip-audit
- test
# === Container Security Scan ===
security-trivy-coordinator:
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="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-coordinator:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-coordinator
# === 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-coordinator"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-coordinator
-170
View File
@@ -1,170 +0,0 @@
# Infrastructure Pipeline - Mosaic Stack
# Docker build, Trivy scan, and publish for postgres + openbao images
#
# Triggers on: docker/**
# No quality gates — infrastructure images (base image + config only)
when:
- event: [push, manual, tag]
path:
include:
- "docker/**"
- ".woodpecker/infra.yml"
variables:
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Docker Build & Push ===
docker-build-postgres:
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-postgres:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
fi
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
docker-build-openbao:
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-openbao:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
fi
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
# === Container Security Scans ===
security-trivy-postgres:
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="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-postgres:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-postgres
security-trivy-openbao:
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="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-openbao:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-openbao
# === 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-postgres"
link_package "stack-openbao"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-postgres
- security-trivy-openbao
-202
View File
@@ -1,202 +0,0 @@
# 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
- &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
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"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/orchestrator
depends_on:
- install
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/orchestrator
depends_on:
- install
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo test --filter=@mosaic/orchestrator
depends_on:
- install
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
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"
fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
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="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
# === 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]
event: [push, manual, tag]
depends_on:
- security-trivy-orchestrator
@@ -1,92 +0,0 @@
{
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "string",
"description": "Brief overall assessment of the code changes"
},
"verdict": {
"type": "string",
"enum": ["approve", "request-changes", "comment"],
"description": "Overall review verdict"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score for the review (0-1)"
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"severity": {
"type": "string",
"enum": ["blocker", "should-fix", "suggestion"],
"description": "Finding severity: blocker (must fix), should-fix (important), suggestion (optional)"
},
"title": {
"type": "string",
"description": "Short title describing the issue"
},
"file": {
"type": "string",
"description": "File path where the issue was found"
},
"line_start": {
"type": "integer",
"description": "Starting line number"
},
"line_end": {
"type": "integer",
"description": "Ending line number"
},
"description": {
"type": "string",
"description": "Detailed explanation of the issue"
},
"suggestion": {
"type": "string",
"description": "Suggested fix or improvement"
}
},
"required": [
"severity",
"title",
"file",
"line_start",
"line_end",
"description",
"suggestion"
]
}
},
"stats": {
"type": "object",
"additionalProperties": false,
"properties": {
"files_reviewed": {
"type": "integer",
"description": "Number of files reviewed"
},
"blockers": {
"type": "integer",
"description": "Count of blocker findings"
},
"should_fix": {
"type": "integer",
"description": "Count of should-fix findings"
},
"suggestions": {
"type": "integer",
"description": "Count of suggestion findings"
}
},
"required": ["files_reviewed", "blockers", "should_fix", "suggestions"]
}
},
"required": ["summary", "verdict", "confidence", "findings", "stats"]
}
@@ -1,106 +0,0 @@
{
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "string",
"description": "Brief overall security assessment of the code changes"
},
"risk_level": {
"type": "string",
"enum": ["critical", "high", "medium", "low", "none"],
"description": "Overall security risk level"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score for the review (0-1)"
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"severity": {
"type": "string",
"enum": ["critical", "high", "medium", "low"],
"description": "Vulnerability severity level"
},
"title": {
"type": "string",
"description": "Short title describing the vulnerability"
},
"file": {
"type": "string",
"description": "File path where the vulnerability was found"
},
"line_start": {
"type": "integer",
"description": "Starting line number"
},
"line_end": {
"type": "integer",
"description": "Ending line number"
},
"description": {
"type": "string",
"description": "Detailed explanation of the vulnerability"
},
"cwe_id": {
"type": "string",
"description": "CWE identifier if applicable (e.g., CWE-79)"
},
"owasp_category": {
"type": "string",
"description": "OWASP Top 10 category if applicable (e.g., A03:2021-Injection)"
},
"remediation": {
"type": "string",
"description": "Specific remediation steps to fix the vulnerability"
}
},
"required": [
"severity",
"title",
"file",
"line_start",
"line_end",
"description",
"cwe_id",
"owasp_category",
"remediation"
]
}
},
"stats": {
"type": "object",
"additionalProperties": false,
"properties": {
"files_reviewed": {
"type": "integer",
"description": "Number of files reviewed"
},
"critical": {
"type": "integer",
"description": "Count of critical findings"
},
"high": {
"type": "integer",
"description": "Count of high findings"
},
"medium": {
"type": "integer",
"description": "Count of medium findings"
},
"low": {
"type": "integer",
"description": "Count of low findings"
}
},
"required": ["files_reviewed", "critical", "high", "medium", "low"]
}
},
"required": ["summary", "risk_level", "confidence", "findings", "stats"]
}
-202
View File
@@ -1,202 +0,0 @@
# 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
- &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
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"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/web
depends_on:
- install
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/web
depends_on:
- install
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo test --filter=@mosaic/web
depends_on:
- install
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
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"
fi
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when:
- branch: [main]
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="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 ===
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]
event: [push, manual, tag]
depends_on:
- security-trivy-web
+45 -72
View File
@@ -1,82 +1,55 @@
# Mosaic Stack — Agent Guidelines
# Agent Guidelines — Mosaic Stack
## Load Order
## Required Load Order
1. `SOUL.md` (repo identity + behavior invariants)
2. `~/.config/mosaic/STANDARDS.md` (machine-wide standards rails)
3. `AGENTS.md` (repo-specific overlay)
4. `.mosaic/repo-hooks.sh` (repo lifecycle hooks)
1. `~/.config/mosaic/SOUL.md`
2. `~/.config/mosaic/STANDARDS.md`
3. `~/.config/mosaic/AGENTS.md`
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
5. `AGENTS.md` (this file)
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
## Runtime Contract
## Project Context
- This file is authoritative for repo-local operations.
- `CLAUDE.md` is a compatibility pointer to `AGENTS.md`.
- Follow universal rails from `~/.config/mosaic/guides/` and `~/.config/mosaic/rails/`.
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
## Session Lifecycle
## Package Map
| Package | Purpose | Key Dependencies |
| ------------------ | ------------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaic/db |
| `packages/brain` | Data layer (PG-backed) | @mosaic/db |
| `packages/queue` | Valkey task queue + MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaic/queue |
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
## Architecture Rules
1. Gateway is the single API surface — all clients connect through it
2. Pi SDK is ESM-only — gateway and CLI must use ESM
3. Socket.IO typed events defined in `@mosaic/types` enforce compile-time contracts
4. OTEL auto-instrumentation loads before NestJS bootstrap
5. BetterAuth manages auth tables; schema defined in `@mosaic/db`
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
## Development Workflow
```bash
bash scripts/agent/session-start.sh
bash scripts/agent/critical.sh
bash scripts/agent/session-end.sh
docker compose up -d # Infrastructure
pnpm install # Dependencies
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
```
Optional:
## Repo-Specific Notes
```bash
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
- Platform: multi-tenant personal assistant stack
- Monorepo: `pnpm` workspaces + Turborepo
- Core apps: `apps/api` (NestJS), `apps/web` (Next.js), orchestrator/coordinator services
- Infrastructure: Docker Compose + PostgreSQL + Valkey + Authentik
## Quick Command Set
```bash
pnpm install
pnpm dev
pnpm test
pnpm lint
pnpm build
```
## Versioning Protocol (HARD GATE)
**This project is ALPHA. All versions MUST be `0.0.x`.**
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
- Every milestone bump increments the patch: `0.0.20``0.0.21``0.0.22`, etc.
- ALL package.json files in the monorepo MUST stay in sync at the same version.
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
- The script rejects any version >= `0.1.0`.
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
## Standards and Quality
- Enforce strict typing and no unsafe shortcuts.
- Keep lint/typecheck/tests green before completion.
- Prefer small, focused commits and clear change descriptions.
## App-Specific Overlays
- `apps/api/AGENTS.md`
- `apps/web/AGENTS.md`
- `apps/coordinator/AGENTS.md`
- `apps/orchestrator/AGENTS.md`
## Additional Guidance
- Orchestrator guidance: `docs/claude/orchestrator.md`
- Security remediation context: `docs/reports/codebase-review-2026-02-05/01-security-review.md`
- Code quality context: `docs/reports/codebase-review-2026-02-05/02-code-quality-review.md`
- QA context: `docs/reports/codebase-review-2026-02-05/03-qa-test-coverage.md`
- DTOs in `*.dto.ts` files at module boundaries
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks
+41 -6
View File
@@ -1,10 +1,45 @@
# CLAUDE Compatibility Pointer
# CLAUDE.md — Mosaic Stack
This file exists so Claude Code sessions load Mosaic standards.
## Project
## MANDATORY — Read Before Any Response
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`.
## Stack
That file is the universal agent configuration. Do NOT respond until you have loaded it.
Then read the project-local `AGENTS.md` in this repository for project-specific guidance.
- **API**: NestJS + Fastify adapter (`apps/gateway`)
- **Web**: Next.js 16 + React 19 (`apps/web`)
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
- **Auth**: BetterAuth (`packages/auth`)
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
- **Queue**: Valkey 8 (`packages/queue`)
- **Build**: pnpm workspaces + Turborepo
- **CI**: Woodpecker CI
- **Observability**: OpenTelemetry → Jaeger
## Commands
```bash
pnpm typecheck # TypeScript check (all packages)
pnpm lint # ESLint (all packages)
pnpm format:check # Prettier check
pnpm test # Vitest (all packages)
pnpm build # Build all packages
# Database
pnpm --filter @mosaic/db db:push # Push schema to PG (dev)
pnpm --filter @mosaic/db db:generate # Generate migrations
pnpm --filter @mosaic/db db:migrate # Run migrations
# Dev
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
pnpm --filter @mosaic/gateway exec tsx src/main.ts # Start gateway
```
## Conventions
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
- DTOs in `*.dto.ts` files at module boundaries
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
- All three gates must pass before push: typecheck, lint, format:check
-61
View File
@@ -1,61 +0,0 @@
# Cron Job Configuration - Issue #29
## Overview
Implement cron job configuration for Mosaic Stack, likely as a MoltBot plugin for scheduled reminders/commands.
## Requirements (inferred from CLAUDE.md pattern)
### Plugin Structure
```
plugins/mosaic-plugin-cron/
├── SKILL.md # MoltBot skill definition
├── src/
│ └── cron.service.ts
└── cron.service.test.ts
```
### Core Features
1. Create/update/delete cron schedules
2. Trigger MoltBot commands on schedule
3. Workspace-scoped (RLS)
4. PDA-friendly UI
### API Endpoints (inferred)
- `POST /api/cron` - Create schedule
- `GET /api/cron` - List schedules
- `DELETE /api/cron/:id` - Delete schedule
### Database (Prisma)
```prisma
model CronSchedule {
id String @id @default(uuid())
workspaceId String
expression String // cron expression
command String // MoltBot command to trigger
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([workspaceId])
}
```
## TDD Approach
1. **RED** - Write tests for CronService
2. **GREEN** - Implement minimal service
3. **REFACTOR** - Add CRUD controller + API endpoints
## Next Steps
- [ ] Create feature branch: `git checkout -b feature/29-cron-config`
- [ ] Write failing tests for cron service
- [ ] Implement service (Green)
- [ ] Add controller & routes
- [ ] Add Prisma schema migration
- [ ] Create MoltBot skill (SKILL.md)
-145
View File
@@ -1,145 +0,0 @@
.PHONY: help install dev build test docker-up docker-down docker-logs docker-ps docker-build docker-restart docker-test speech-up speech-down speech-logs clean matrix-up matrix-down matrix-logs matrix-setup-bot
# Default target
help:
@echo "Mosaic Stack - Available commands:"
@echo ""
@echo "Development:"
@echo " make install Install dependencies"
@echo " make dev Start development servers"
@echo " make build Build all applications"
@echo " make test Run all tests"
@echo " make lint Run linters"
@echo " make format Format code"
@echo ""
@echo "Docker:"
@echo " make docker-up Start Docker services (core)"
@echo " make docker-up-full Start Docker services (all)"
@echo " make docker-up-traefik Start with bundled Traefik"
@echo " make docker-down Stop Docker services"
@echo " make docker-logs View Docker logs"
@echo " make docker-ps Show Docker service status"
@echo " make docker-build Rebuild Docker images"
@echo " make docker-restart Restart Docker services"
@echo " make docker-test Run Docker smoke test"
@echo " make docker-test-traefik Run Traefik integration tests"
@echo ""
@echo "Speech Services:"
@echo " make speech-up Start speech services (STT + TTS)"
@echo " make speech-down Stop speech services"
@echo " make speech-logs View speech service logs"
@echo ""
@echo "Matrix Dev Environment:"
@echo " make matrix-up Start Matrix services (Synapse + Element)"
@echo " make matrix-down Stop Matrix services"
@echo " make matrix-logs View Matrix service logs"
@echo " make matrix-setup-bot Create bot account and get access token"
@echo ""
@echo "Database:"
@echo " make db-migrate Run database migrations"
@echo " make db-seed Seed development data"
@echo " make db-studio Open Prisma Studio"
@echo " make db-reset Reset database (WARNING: deletes data)"
@echo ""
@echo "Cleanup:"
@echo " make clean Clean build artifacts"
@echo " make clean-all Clean everything including node_modules"
@echo " make docker-clean Remove Docker containers and volumes"
# Development
install:
pnpm install
dev:
pnpm dev
build:
pnpm build
test:
pnpm test
lint:
pnpm lint
format:
pnpm format
# Docker operations
docker-up:
docker compose up -d
docker-up-full:
docker compose --profile full up -d
docker-up-traefik:
docker compose --profile traefik-bundled up -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f
docker-ps:
docker compose ps
docker-build:
docker compose build
docker-restart:
docker compose restart
docker-test:
./scripts/test-docker-deployment.sh
docker-test-traefik:
./tests/integration/docker/traefik.test.sh all
# Speech services
speech-up:
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d speaches kokoro-tts
speech-down:
docker compose -f docker-compose.yml -f docker-compose.speech.yml down --remove-orphans
speech-logs:
docker compose -f docker-compose.yml -f docker-compose.speech.yml logs -f speaches kokoro-tts
# Matrix Dev Environment
matrix-up:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up -d
matrix-down:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml down
matrix-logs:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml logs -f synapse element-web
matrix-setup-bot:
docker/matrix/scripts/setup-bot.sh
# Database operations
db-migrate:
cd apps/api && pnpm prisma:migrate
db-seed:
cd apps/api && pnpm prisma:seed
db-studio:
cd apps/api && pnpm prisma:studio
db-reset:
cd apps/api && pnpm prisma:reset
# Cleanup
clean:
pnpm clean
clean-all:
pnpm clean
rm -rf node_modules
docker-clean:
docker compose down -v
docker system prune -f
-817
View File
@@ -1,817 +0,0 @@
# Mosaic Stack
Multi-tenant personal assistant platform with PostgreSQL backend, Authentik SSO, and MoltBot integration.
## Overview
Mosaic Stack is a modern, PDA-friendly platform designed to help users manage their personal and professional lives with:
- **Multi-user workspaces** with team collaboration
- **Knowledge management** with wiki-style linking and version history
- **Task management** with flexible organization
- **Event & calendar** integration
- **Project tracking** with Gantt charts and Kanban boards
- **MoltBot integration** for natural language interactions
- **Authentik OIDC** for secure, enterprise-grade authentication
**Version:** 0.0.1 (Pre-MVP)
**Repository:** https://git.mosaicstack.dev/mosaic/stack
## Technology Stack
| Layer | Technology |
| -------------- | ---------------------------------------------- |
| **Frontend** | Next.js 16 + React + TailwindCSS + Shadcn/ui |
| **Backend** | NestJS + Prisma ORM |
| **Database** | PostgreSQL 17 + pgvector |
| **Cache** | Valkey (Redis-compatible) |
| **Auth** | Authentik (OIDC) via BetterAuth |
| **AI** | Ollama (local or remote) |
| **Messaging** | MoltBot (stock + plugins) |
| **Real-time** | WebSockets (Socket.io) |
| **Speech** | Speaches (STT) + Kokoro/Chatterbox/Piper (TTS) |
| **Monorepo** | pnpm workspaces + TurboRepo |
| **Testing** | Vitest + Playwright |
| **Deployment** | Docker + docker-compose |
## Quick Start
### One-Line Install (Recommended)
The fastest way to get Mosaic Stack running on macOS or Linux:
```bash
curl -fsSL https://get.mosaicstack.dev | bash
```
This installer:
- ✅ Detects your platform (macOS, Debian/Ubuntu, Arch, Fedora)
- ✅ Installs all required dependencies (Docker, Node.js, etc.)
- ✅ Generates secure secrets automatically
- ✅ Configures the environment for you
- ✅ Starts all services with Docker Compose
- ✅ Validates the installation with health checks
**Installer Options:**
```bash
# Non-interactive Docker deployment
curl -fsSL https://get.mosaicstack.dev | bash -s -- --non-interactive --mode docker
# Preview installation without making changes
curl -fsSL https://get.mosaicstack.dev | bash -s -- --dry-run
# With SSO and local Ollama
curl -fsSL https://get.mosaicstack.dev | bash -s -- \
--mode docker \
--enable-sso --bundled-authentik \
--ollama-mode local
# Skip dependency installation (if already installed)
curl -fsSL https://get.mosaicstack.dev | bash -s -- --skip-deps
```
**After Installation:**
```bash
# Check system health
./scripts/commands/doctor.sh
# View service logs
docker compose logs -f
# Stop services
docker compose down
```
### Prerequisites
If you prefer manual installation, you'll need:
- **Docker mode:** Docker 24+ and Docker Compose
- **Native mode:** Node.js 24+, pnpm 10+, PostgreSQL 17+
The installer handles these automatically.
### Manual Installation
```bash
# Clone the repository
git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack
cd mosaic-stack
# Install dependencies
pnpm install
# Copy environment file
cp .env.example .env
# Configure environment variables (see CONFIGURATION.md)
# Edit .env with your database and auth settings
# Generate Prisma client
pnpm prisma:generate
# Run database migrations
pnpm prisma:migrate
# Seed development data (optional)
pnpm prisma:seed
# Start development servers
pnpm dev
```
### Docker Deployment
**Recommended for quick setup and production deployments.**
#### Development (Turnkey - All Services Bundled)
```bash
# Clone repository
git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack
cd mosaic-stack
# Copy and configure environment
cp .env.example .env
# Set COMPOSE_PROFILES=full in .env
# Start all services (PostgreSQL, Valkey, OpenBao, Authentik, Ollama, API, Web)
docker compose up -d
# View logs
docker compose logs -f
# Access services
# Web: http://localhost:3000
# API: http://localhost:3001
# Auth: http://localhost:9000
```
#### Production (External Managed Services)
```bash
# Clone repository
git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack
cd mosaic-stack
# Copy environment template and example
cp .env.example .env
cp docker/docker-compose.example.external.yml docker-compose.override.yml
# Edit .env with external service URLs:
# - DATABASE_URL=postgresql://... (RDS, Cloud SQL, etc.)
# - VALKEY_URL=redis://... (ElastiCache, Memorystore, etc.)
# - OPENBAO_ADDR=https://... (HashiCorp Vault, etc.)
# - OIDC_ISSUER=https://... (Auth0, Okta, etc.)
# - Set COMPOSE_PROFILES= (empty)
# Start API and Web only
docker compose up -d
# View logs
docker compose logs -f
```
#### Hybrid (Mix of Bundled and External)
```bash
# Use bundled database/cache, external auth/secrets
cp docker/docker-compose.example.hybrid.yml docker-compose.override.yml
# Edit .env:
# - COMPOSE_PROFILES=database,cache,ollama
# - OPENBAO_ADDR=https://... (external vault)
# - OIDC_ISSUER=https://... (external auth)
# Start mixed deployment
docker compose up -d
```
**Stop services:**
```bash
docker compose down
```
**What's included:**
- PostgreSQL 17 with pgvector extension
- Valkey (Redis-compatible cache)
- Mosaic API (NestJS)
- Mosaic Web (Next.js)
- Mosaic Orchestrator (Agent lifecycle management)
- Mosaic Coordinator (Task assignment & monitoring)
- Authentik OIDC (optional, use `--profile authentik`)
- Ollama AI (optional, use `--profile ollama`)
See [Docker Deployment Guide](docs/1-getting-started/4-docker-deployment/) for complete documentation.
### Docker Swarm Deployment (Production)
**Recommended for production deployments with high availability and auto-scaling.**
Deploy to a Docker Swarm cluster with integrated Traefik reverse proxy:
```bash
# 1. Initialize swarm (if not already done)
docker swarm init --advertise-addr <your-ip>
# 2. Create Traefik network
docker network create --driver=overlay traefik-public
# 3. Configure environment for swarm
cp .env.swarm.example .env
nano .env # Configure domains, passwords, API keys
# 4. CRITICAL: Deploy OpenBao standalone FIRST
# OpenBao cannot run in swarm mode - deploy as standalone container
docker compose -f docker-compose.openbao.yml up -d
sleep 30 # Wait for auto-initialization
# 5. Deploy swarm stack
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
# 6. Check deployment status
docker stack services mosaic
docker stack ps mosaic
# Access services via Traefik
# Web: http://mosaic.mosaicstack.dev
# API: http://api.mosaicstack.dev
# Auth: http://auth.mosaicstack.dev (if using bundled Authentik)
```
**Key features:**
- Automatic Traefik integration for routing
- Overlay networking for multi-host deployments
- Built-in health checks and rolling updates
- Horizontal scaling for web and API services
- Zero-downtime deployments
- Service orchestration across multiple nodes
**Important Notes:**
- **OpenBao Requirement:** OpenBao MUST be deployed as standalone container (not in swarm). Use `docker-compose.openbao.yml` or external Vault.
- Swarm does NOT support docker-compose profiles
- To use external services (PostgreSQL, Authentik, etc.), manually comment them out in `docker-compose.swarm.yml`
See [Docker Swarm Deployment Guide](docs/SWARM-DEPLOYMENT.md) and [Quick Reference](docs/SWARM-QUICKREF.md) for complete documentation.
### Portainer Deployment
**Recommended for GUI-based stack management.**
Portainer provides a web UI for managing Docker containers and stacks. Use the Portainer-optimized compose file:
**File:** `docker-compose.portainer.yml`
**Key differences from standard compose:**
- No `env_file` directive (define variables in Portainer UI)
- Port exposed on all interfaces (Portainer limitation)
- Optimized for Portainer's stack parser
**Quick Steps:**
1. Create `mosaic_internal` overlay network in Portainer
2. Deploy `mosaic-openbao` stack with `docker-compose.portainer.yml`
3. Deploy `mosaic` swarm stack with `docker-compose.swarm.yml`
4. Configure environment variables in Portainer UI
See [Portainer Deployment Guide](docs/PORTAINER-DEPLOYMENT.md) for detailed instructions.
## Project Structure
```
mosaic-stack/
├── apps/
│ ├── api/ # NestJS backend API
│ │ ├── src/
│ │ │ ├── auth/ # BetterAuth + Authentik OIDC
│ │ │ ├── prisma/ # Database service
│ │ │ ├── coordinator-integration/ # Coordinator API client
│ │ │ └── app.module.ts # Main application module
│ │ ├── prisma/
│ │ │ └── schema.prisma # Database schema
│ │ └── Dockerfile
│ ├── web/ # Next.js 16 frontend
│ │ ├── app/
│ │ ├── components/
│ │ │ └── widgets/ # HUD widgets (agent status, etc.)
│ │ └── Dockerfile
│ ├── orchestrator/ # Agent lifecycle & spawning (NestJS)
│ │ ├── src/
│ │ │ ├── spawner/ # Agent spawning service
│ │ │ ├── queue/ # Valkey-backed task queue
│ │ │ ├── monitor/ # Health monitoring
│ │ │ ├── git/ # Git worktree management
│ │ │ └── killswitch/ # Emergency agent termination
│ │ └── Dockerfile
│ └── coordinator/ # Task assignment & monitoring (FastAPI)
│ ├── src/
│ │ ├── webhook.py # Gitea webhook receiver
│ │ ├── parser.py # Issue metadata parser
│ │ └── security.py # HMAC signature verification
│ └── Dockerfile
├── packages/
│ ├── shared/ # Shared types & utilities
│ │ └── src/types/
│ │ ├── auth.types.ts # Auth types (AuthUser, Session, etc.)
│ │ ├── database.types.ts # DB entity types
│ │ └── enums.ts # Shared enums
│ ├── ui/ # Shared UI components (planned)
│ └── config/ # Shared configuration (planned)
├── plugins/ # MoltBot skills (planned)
│ ├── mosaic-plugin-brain/ # API query skill
│ ├── mosaic-plugin-calendar/ # Calendar skill
│ └── mosaic-plugin-tasks/ # Task management skill
├── docker/
│ ├── docker-compose.yml # Production deployment
│ └── init-scripts/ # PostgreSQL initialization
├── docs/
│ ├── SETUP.md # Installation guide
│ ├── CONFIGURATION.md # Environment configuration
│ ├── DESIGN-PRINCIPLES.md # PDA-friendly design patterns
│ ├── API.md # API documentation
│ ├── TYPE-SHARING.md # Type sharing strategy
│ └── scratchpads/ # Development notes
├── .env.example # Environment template
├── turbo.json # TurboRepo configuration
└── pnpm-workspace.yaml # Workspace configuration
```
## Agent Orchestration Layer (v0.0.6)
Mosaic Stack includes a sophisticated agent orchestration system for autonomous task execution:
- **Orchestrator Service** (NestJS) - Manages agent lifecycle, spawning, and health monitoring
- **Coordinator Service** (FastAPI) - Receives Gitea webhooks, assigns tasks to agents
- **Task Queue** - Valkey-backed queue for distributed task management
- **Git Worktrees** - Isolated workspaces for parallel agent execution
- **Killswitch** - Emergency stop mechanism for runaway agents
- **Agent Dashboard** - Real-time monitoring UI with status widgets
See [Agent Orchestration Design](docs/design/agent-orchestration.md) for architecture details.
## Speech Services
Mosaic Stack includes integrated speech-to-text (STT) and text-to-speech (TTS) capabilities through a modular provider architecture. Each component is optional and independently configurable.
- **Speech-to-Text** - Transcribe audio files and real-time audio streams using Whisper (via Speaches)
- **Text-to-Speech** - Synthesize speech with 54+ voices across 8 languages (via Kokoro, CPU-based)
- **Premium Voice Cloning** - Clone voices from audio samples with emotion control (via Chatterbox, GPU)
- **Fallback TTS** - Ultra-lightweight CPU fallback for low-resource environments (via Piper/OpenedAI Speech)
- **WebSocket Streaming** - Real-time streaming transcription via Socket.IO `/speech` namespace
- **Automatic Fallback** - TTS tier system with graceful degradation (premium -> default -> fallback)
**Quick Start:**
```bash
# Start speech services alongside core stack
make speech-up
# Or with Docker Compose directly
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d
```
See [Speech Services Documentation](docs/SPEECH.md) for architecture details, API reference, provider configuration, and deployment options.
## Current Implementation Status
### ✅ Completed (v0.0.1-0.0.6)
- **M1-Foundation:** Project scaffold, PostgreSQL 17 + pgvector, Prisma ORM
- **M2-MultiTenant:** Workspace isolation with RLS, team management
- **M3-Features:** Knowledge management, tasks, calendar, authentication
- **M4-MoltBot:** Bot integration architecture (in progress)
- **M6-AgentOrchestration:** Orchestrator service, coordinator, agent dashboard ✅
**Test Coverage:** 2168+ tests passing
### 🚧 In Progress (v0.0.x)
- Agent orchestration E2E testing
- Usage budget management
- Performance optimization
### 📋 Planned Features (v0.1.0 MVP)
- Event/calendar management
- Project tracking with Gantt charts
- MoltBot integration
- WebSocket real-time updates
- Data migration from jarvis-brain
See the [issue tracker](https://git.mosaicstack.dev/mosaic/stack/issues) for complete roadmap.
## Knowledge Module
The **Knowledge Module** is a powerful personal wiki and knowledge management system built into Mosaic Stack. Create interconnected notes, organize with tags, track changes over time, and visualize relationships.
### Features
- **📝 Markdown-based entries** — Write using familiar Markdown syntax
- **🔗 Wiki-style linking** — Connect entries using `[[wiki-links]]`
- **🏷️ Tag organization** — Categorize and filter with flexible tagging
- **📜 Full version history** — Every edit is tracked and recoverable
- **🔍 Powerful search** — Full-text search across titles and content
- **📊 Knowledge graph** — Visualize relationships between entries
- **📤 Import/Export** — Bulk import/export for portability
- **⚡ Valkey caching** — High-performance caching for fast access
### Quick Examples
**Create an entry:**
```bash
curl -X POST http://localhost:3001/api/knowledge/entries \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "x-workspace-id: WORKSPACE_ID" \
-d '{
"title": "React Hooks Guide",
"content": "# React Hooks\n\nSee [[Component Patterns]] for more.",
"tags": ["react", "frontend"],
"status": "PUBLISHED"
}'
```
**Search entries:**
```bash
curl -X GET 'http://localhost:3001/api/knowledge/search?q=react+hooks' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "x-workspace-id: WORKSPACE_ID"
```
**Export knowledge base:**
```bash
curl -X GET 'http://localhost:3001/api/knowledge/export?format=markdown' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "x-workspace-id: WORKSPACE_ID" \
-o knowledge-export.zip
```
### Documentation
- **[User Guide](KNOWLEDGE_USER_GUIDE.md)** — Getting started, features, and workflows
- **[API Documentation](KNOWLEDGE_API.md)** — Complete REST API reference with examples
- **[Developer Guide](KNOWLEDGE_DEV.md)** — Architecture, implementation, and contributing
### Key Concepts
**Wiki-links**
Connect entries using double-bracket syntax:
```markdown
See [[Entry Title]] or [[entry-slug]] for details.
Use [[Page|custom text]] for custom display text.
```
**Version History**
Every edit creates a new version. View history, compare changes, and restore previous versions:
```bash
# List versions
GET /api/knowledge/entries/:slug/versions
# Get specific version
GET /api/knowledge/entries/:slug/versions/:version
# Restore version
POST /api/knowledge/entries/:slug/restore/:version
```
**Backlinks**
Automatically discover entries that link to a given entry:
```bash
GET /api/knowledge/entries/:slug/backlinks
```
**Tags**
Organize entries with tags:
```bash
# Create tag
POST /api/knowledge/tags
{ "name": "React", "color": "#61dafb" }
# Find entries with tags
GET /api/knowledge/search/by-tags?tags=react,frontend
```
### Performance
With Valkey caching enabled:
- **Entry retrieval:** ~2-5ms (vs ~50ms uncached)
- **Search queries:** ~2-5ms (vs ~200ms uncached)
- **Graph traversals:** ~2-5ms (vs ~400ms uncached)
- **Cache hit rates:** 70-90% for active workspaces
Configure caching via environment variables:
```bash
VALKEY_URL=redis://localhost:6379
KNOWLEDGE_CACHE_ENABLED=true
KNOWLEDGE_CACHE_TTL=300 # 5 minutes
```
## Development Workflow
### Branch Strategy
- `main` — Trunk branch (all development merges here)
- `feature/*` — Feature branches from main
- `fix/*` — Bug fix branches from main
### Running Locally
```bash
# Start all services (requires Docker for PostgreSQL)
pnpm dev # All apps
# Or run individually
pnpm dev:api # API only (http://localhost:3001)
pnpm dev:web # Web only (http://localhost:3000)
# Database tools
pnpm prisma:studio # Open Prisma Studio
pnpm prisma:migrate # Run migrations
pnpm prisma:generate # Regenerate Prisma client
```
### Testing
```bash
pnpm test # All tests
pnpm test:api # API tests only
pnpm test:web # Web tests only
pnpm test:e2e # E2E tests with Playwright
pnpm test:coverage # Generate coverage report
```
### Building
```bash
pnpm build # Build all apps
pnpm build:api # Build API only
pnpm build:web # Build web only
```
## Design Philosophy
Mosaic Stack follows strict **PDA-friendly design principles**:
### Language Guidelines
We **never** use demanding or stressful language:
| ❌ NEVER | ✅ ALWAYS |
| ----------- | -------------------- |
| OVERDUE | Target passed |
| URGENT | Approaching target |
| MUST DO | Scheduled for |
| CRITICAL | High priority |
| YOU NEED TO | Consider / Option to |
| REQUIRED | Recommended |
### Visual Principles
- **10-second scannability** — Key info visible immediately
- **Visual chunking** — Clear sections with headers
- **Single-line items** — Compact, scannable lists
- **Calm colors** — No aggressive reds for status indicators
- **Progressive disclosure** — Details on click, not upfront
See [Design Principles](docs/3-architecture/3-design-principles/1-pda-friendly.md) for complete guidelines.
## API Conventions
### Endpoints
```
GET /api/{resource} # List (with pagination, filters)
GET /api/{resource}/:id # Get single item
POST /api/{resource} # Create new item
PATCH /api/{resource}/:id # Update item
DELETE /api/{resource}/:id # Delete item
```
### Authentication
All authenticated endpoints require a Bearer token:
```http
Authorization: Bearer {session_token}
```
See [API Reference](docs/4-api/) for complete API documentation.
## Configuration
Key environment variables:
```bash
# Database
DATABASE_URL=postgresql://mosaic:password@localhost:5432/mosaic
# Authentik OIDC
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
# JWT Session
JWT_SECRET=change-this-to-a-random-secret-in-production
JWT_EXPIRATION=24h
# Application
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
See [Configuration](docs/1-getting-started/3-configuration/1-environment.md) for all configuration options.
## Caching
Mosaic Stack uses **Valkey** (Redis-compatible) for high-performance caching, significantly improving response times for frequently accessed data.
### Knowledge Module Caching
The Knowledge module implements intelligent caching for:
- **Entry Details** - Individual knowledge entries (GET `/api/knowledge/entries/:slug`)
- **Search Results** - Full-text search queries with filters
- **Graph Queries** - Knowledge graph traversals with depth limits
### Cache Configuration
Configure caching via environment variables:
```bash
# Valkey connection
VALKEY_URL=redis://localhost:6379
# Knowledge cache settings
KNOWLEDGE_CACHE_ENABLED=true # Set to false to disable caching (dev mode)
KNOWLEDGE_CACHE_TTL=300 # Time-to-live in seconds (default: 5 minutes)
```
### Cache Invalidation Strategy
Caches are automatically invalidated on data changes:
- **Entry Updates** - Invalidates entry cache, search caches, and related graph caches
- **Entry Creation** - Invalidates search caches and graph caches
- **Entry Deletion** - Invalidates entry cache, search caches, and graph caches
- **Link Changes** - Invalidates graph caches for affected entries
### Cache Statistics & Management
Monitor and manage caches via REST endpoints:
```bash
# Get cache statistics (hits, misses, hit rate)
GET /api/knowledge/cache/stats
# Clear all caches for a workspace (admin only)
POST /api/knowledge/cache/clear
# Reset cache statistics (admin only)
POST /api/knowledge/cache/stats/reset
```
**Example response:**
```json
{
"enabled": true,
"stats": {
"hits": 1250,
"misses": 180,
"sets": 195,
"deletes": 15,
"hitRate": 0.874
}
}
```
### Performance Benefits
- **Entry retrieval:** ~10-50ms → ~2-5ms (80-90% improvement)
- **Search queries:** ~100-300ms → ~2-5ms (95-98% improvement)
- **Graph traversals:** ~200-500ms → ~2-5ms (95-99% improvement)
Cache hit rates typically stabilize at 70-90% for active workspaces.
## Type Sharing
Types used by both frontend and backend live in `@mosaic/shared`:
```typescript
import type { AuthUser, Task, Event } from "@mosaic/shared";
// Frontend
function UserProfile({ user }: { user: AuthUser }) {
return <div>{user.name}</div>;
}
// Backend
@Get("profile")
@UseGuards(AuthGuard)
getProfile(@CurrentUser() user: AuthUser) {
return user;
}
```
See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for the complete type sharing strategy.
## Contributing
1. Check the [issue tracker](https://git.mosaicstack.dev/mosaic/stack/issues) for open issues
2. Create a feature branch: `git checkout -b feature/my-feature develop`
3. Make your changes with tests (minimum 85% coverage required)
4. Run tests: `pnpm test`
5. Build: `pnpm build`
6. Commit with conventional format: `feat(#issue): Description`
7. Push and create a pull request to `main`
### Commit Format
```
<type>(#issue): Brief description
Detailed explanation if needed.
Fixes #123
```
**Types:** `feat`, `fix`, `docs`, `test`, `refactor`, `chore`
## Testing Requirements
- **Minimum 85% coverage** for new code
- **TDD approach** — Write tests before implementation
- **All tests pass** before PR merge
- Use Vitest for unit/integration tests
- Use Playwright for E2E tests
## Documentation
Complete documentation is organized in a Bookstack-compatible structure in the `docs/` directory.
### 📚 Getting Started
- **[Quick Start](docs/1-getting-started/1-quick-start/1-overview.md)** — Get running in 5 minutes
- **[Installation](docs/1-getting-started/2-installation/)** — Prerequisites, local setup, Docker deployment
- **[Configuration](docs/1-getting-started/3-configuration/)** — Environment variables and Authentik OIDC
### 💻 Development
- **[Workflow](docs/2-development/1-workflow/)** — Branching strategy, testing requirements, commit guidelines
- **[Database](docs/2-development/2-database/)** — Schema design, migrations, Prisma usage
- **[Type Sharing](docs/2-development/3-type-sharing/1-strategy.md)** — Shared types across monorepo
### 🏗️ Architecture
- **[Overview](docs/3-architecture/1-overview/)** — System design and components
- **[Authentication](docs/3-architecture/2-authentication/)** — BetterAuth and OIDC integration
- **[Design Principles](docs/3-architecture/3-design-principles/1-pda-friendly.md)** — PDA-friendly patterns (non-negotiable)
- **[Telemetry](docs/telemetry.md)** — AI task completion tracking, predictions, and SDK reference
### 🔌 API Reference
- **[Conventions](docs/4-api/1-conventions/1-endpoints.md)** — REST patterns, pagination, filtering
- **[Authentication](docs/4-api/2-authentication/1-endpoints.md)** — Auth endpoints and flows
**Browse all documentation:** [docs/](docs/)
## Related Projects
- **jarvis-brain** — Original JSON-based personal assistant (migration source)
- **MoltBot** — Stock messaging gateway for multi-platform integration
## Security
- **Session-based authentication** with secure JWT tokens
- **Row-level security** ready for multi-tenant isolation
- **OIDC integration** with Authentik for enterprise SSO
- **Secure error handling** — No sensitive data in logs or responses
- **Type-safe validation** — TypeScript catches issues at compile time
## License
[Add license information]
## Support
- **Issues:** https://git.mosaicstack.dev/mosaic/stack/issues
- **Documentation:** https://git.mosaicstack.dev/mosaic/stack/wiki
---
**Mosaic Stack v0.0.1** — Building the future of personal assistants.
-20
View File
@@ -1,20 +0,0 @@
# Mosaic Stack Soul
You are Jarvis for the Mosaic Stack repository, running on the current agent runtime.
## Behavioral Invariants
- Identity first: answer identity prompts as Jarvis for this repository.
- Implementation detail second: runtime (Codex/Claude/OpenCode/etc.) is secondary metadata.
- Be proactive: surface risks, blockers, and next actions without waiting.
- Be calm and clear: keep responses concise, chunked, and PDA-friendly.
- Respect canonical sources:
- Repo operations and conventions: `AGENTS.md`
- Machine-wide rails: `~/.config/mosaic/STANDARDS.md`
- Repo lifecycle hooks: `.mosaic/repo-hooks.sh`
## Guardrails
- Do not claim completion without verification evidence.
- Do not bypass lint/type/test quality gates.
- Prefer explicit assumptions and concrete file/command references.
-48
View File
@@ -1,48 +0,0 @@
# Node modules
node_modules
npm-debug.log
yarn-error.log
pnpm-debug.log
# Build output
dist
build
*.tsbuildinfo
# Tests
coverage
.vitest
test
*.spec.ts
*.test.ts
# Development files
.env
.env.*
!.env.example
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Documentation
README.md
docs
# Logs
logs
*.log
# Turbo
.turbo
-40
View File
@@ -1,40 +0,0 @@
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/database
# System Administration
# Comma-separated list of user IDs that have system administrator privileges
# These users can perform system-level operations across all workspaces
# Note: Workspace ownership does NOT grant system admin access
# SYSTEM_ADMIN_IDS=uuid1,uuid2,uuid3
# Federation Instance Identity
# Display name for this Mosaic instance
INSTANCE_NAME=Mosaic Instance
# Publicly accessible URL for federation (must be valid HTTP/HTTPS URL)
INSTANCE_URL=http://localhost:3000
# Encryption (AES-256-GCM for sensitive data at rest)
# CRITICAL: Generate a secure random key for production!
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
# CSRF Protection (Required in production)
# Secret key for HMAC binding CSRF tokens to user sessions
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# In development, a random key is generated if not set
CSRF_SECRET=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
# OpenTelemetry Configuration
# Enable/disable OpenTelemetry tracing (default: true)
OTEL_ENABLED=true
# Service name for telemetry (default: mosaic-api)
OTEL_SERVICE_NAME=mosaic-api
# OTLP exporter endpoint (default: http://localhost:4318/v1/traces)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
# Alternative: Jaeger endpoint (legacy)
# OTEL_EXPORTER_JAEGER_ENDPOINT=http://localhost:4318/v1/traces
# Deployment environment (default: development, or uses NODE_ENV)
# OTEL_DEPLOYMENT_ENVIRONMENT=production
# Trace sampling ratio: 0.0 (none) to 1.0 (all) - default: 1.0
# Use lower values in high-traffic production environments
# OTEL_TRACES_SAMPLER_ARG=1.0
-5
View File
@@ -1,5 +0,0 @@
DATABASE_URL="postgresql://test:test@localhost:5432/test"
ENCRYPTION_KEY="test-encryption-key-32-characters"
JWT_SECRET="test-jwt-secret"
INSTANCE_NAME="Test Instance"
INSTANCE_URL="https://test.example.com"
-9
View File
@@ -1,9 +0,0 @@
# WARNING: These are example test credentials for local integration testing.
# Copy this file to .env.test and customize the values for your local environment.
# NEVER use these credentials in any shared environment or commit .env.test to git.
DATABASE_URL="postgresql://test:test@localhost:5432/test"
ENCRYPTION_KEY="test-encryption-key-32-characters"
JWT_SECRET="test-jwt-secret"
INSTANCE_NAME="Test Instance"
INSTANCE_URL="https://test.example.com"
-18
View File
@@ -1,18 +0,0 @@
{
"sourceMaps": true,
"jsc": {
"target": "es2022",
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"keepClassNames": true
},
"module": {
"type": "es6"
}
}
-25
View File
@@ -1,25 +0,0 @@
# api — Agent Context
> Part of the apps layer.
## Patterns
- **Config validation pattern**: Config files use exported validation functions + typed getter functions (not class-validator). See `auth.config.ts`, `federation.config.ts`, `speech/speech.config.ts`. Pattern: export `isXEnabled()`, `validateXConfig()`, and `getXConfig()` functions.
- **Config registerAs**: `speech.config.ts` also exports a `registerAs("speech", ...)` factory for NestJS ConfigModule namespaced injection. Use `ConfigModule.forFeature(speechConfig)` in module imports and access via `this.config.get<string>('speech.stt.baseUrl')`.
- **Conditional config validation**: When a service has an enabled flag (e.g., `STT_ENABLED`), URL/connection vars are only required when enabled. Validation throws with a helpful message suggesting how to disable.
- **Boolean env parsing**: Use `value === "true" || value === "1"` pattern. No default-true -- all services default to disabled when env var is unset.
## Gotchas
- **Prisma client must be generated** before `tsc --noEmit` will pass. Run `pnpm prisma:generate` first. Pre-existing type errors from Prisma are expected in worktrees without generated client.
- **Pre-commit hooks**: lint-staged runs on staged files. If other packages' files are staged, their lint must pass too. Only stage files you intend to commit.
- **vitest runs all test files**: Even when targeting a specific test file, vitest loads all spec files. Many will fail if Prisma client isn't generated -- this is expected. Check only your target file's pass/fail status.
## Key Files
| File | Purpose |
| ------------------------------------- | ---------------------------------------------------------------------- |
| `src/speech/speech.config.ts` | Speech services env var validation and typed config (STT, TTS, limits) |
| `src/speech/speech.config.spec.ts` | Unit tests for speech config validation (51 tests) |
| `src/auth/auth.config.ts` | Auth/OIDC config validation (reference pattern) |
| `src/federation/federation.config.ts` | Federation config validation (reference pattern) |
-115
View File
@@ -1,115 +0,0 @@
# Base image for all stages
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
FROM node:24-slim AS base
# Install pnpm globally
RUN corepack enable && corepack prepare [email protected] --activate
# Set working directory
WORKDIR /app
# Copy monorepo configuration files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY turbo.json ./
# ======================
# Dependencies stage
# ======================
FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation)
# and OpenSSL for Prisma engine detection
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy all package.json files for workspace resolution
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
# 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
# ======================
FROM base AS builder
# Copy root node_modules from deps
COPY --from=deps /app/node_modules ./node_modules
# Copy all source code FIRST
COPY packages ./packages
COPY apps/api ./apps/api
# Then copy workspace node_modules from deps (these go AFTER source to avoid being overwritten)
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
# Build the API app and its dependencies using TurboRepo
# --force disables turbo cache to ensure fresh build from source
RUN pnpm turbo build --filter=@mosaic/api --force
# ======================
# Production stage
# ======================
FROM node:24-slim AS production
# Install dumb-init for proper signal handling (static binary from GitHub,
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - openssl: Prisma engine detection requires libssl
# - No build tools needed here — native addons are compiled in the deps stage
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
WORKDIR /app
# Copy node_modules from builder (includes generated Prisma client in pnpm store)
# pnpm stores the Prisma client in node_modules/.pnpm/.../.prisma, so we need the full tree
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
# Copy built packages (includes dist/ directories)
COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages
# Copy built API application
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/dist ./apps/api/dist
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/package.json ./apps/api/
# Copy app's node_modules which contains symlinks to root node_modules
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/node_modules ./apps/api/node_modules
# Copy entrypoint script (runs migrations before starting app)
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/docker-entrypoint.sh ./apps/api/
# Set working directory to API app
WORKDIR /app/apps/api
# Switch to non-root user
USER nestjs
# Expose API port (default 3001, can be overridden via PORT env var)
EXPOSE ${PORT:-3001}
# Health check uses PORT env var (set by docker-compose or defaults to 3001)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "const port = process.env.PORT || 3001; require('http').get('http://localhost:' + port + '/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Run migrations then start the application
CMD ["sh", "docker-entrypoint.sh"]
-260
View File
@@ -1,260 +0,0 @@
# Mosaic Stack API
The Mosaic Stack API is a NestJS-based backend service providing REST endpoints and WebSocket support for the Mosaic productivity platform.
## Overview
The API serves as the central backend for:
- **Task Management** - Create, update, track tasks with filtering and sorting
- **Event Management** - Calendar events and scheduling
- **Project Management** - Organize work into projects
- **Knowledge Base** - Wiki-style documentation with markdown support and wiki-linking
- **Ideas** - Quick capture and organization of ideas
- **Domains** - Categorize work across different domains
- **Personalities** - AI personality configurations for the Ollama integration
- **Widgets & Layouts** - Dashboard customization
- **Activity Logging** - Track all user actions
- **WebSocket Events** - Real-time updates for tasks, events, and projects
## Available Modules
| Module | Base Path | Description |
| ------------------ | --------------------------- | ---------------------------------------- |
| **Tasks** | `/api/tasks` | CRUD operations for tasks with filtering |
| **Events** | `/api/events` | Calendar events and scheduling |
| **Projects** | `/api/projects` | Project management |
| **Knowledge** | `/api/knowledge/entries` | Wiki entries with markdown support |
| **Knowledge Tags** | `/api/knowledge/tags` | Tag management for knowledge entries |
| **Ideas** | `/api/ideas` | Quick capture and idea management |
| **Domains** | `/api/domains` | Domain categorization |
| **Personalities** | `/api/personalities` | AI personality configurations |
| **Widgets** | `/api/widgets` | Dashboard widget data |
| **Layouts** | `/api/layouts` | Dashboard layout configuration |
| **Ollama** | `/api/ollama` | LLM integration (generate, chat, embed) |
| **Users** | `/api/users/me/preferences` | User preferences |
### Health Check
- `GET /` - API health check
- `GET /health` - Detailed health status including database connectivity
## Authentication
The API uses **BetterAuth** for authentication with the following features:
### Authentication Flow
1. **Email/Password** - Users can sign up and log in with email and password
2. **Session Tokens** - BetterAuth generates session tokens with configurable expiration
### Guards
The API uses a layered guard system:
| Guard | Purpose | Applies To |
| ------------------- | ------------------------------------------------------------------------ | -------------------------- |
| **AuthGuard** | Verifies user authentication via Bearer token | Most protected endpoints |
| **WorkspaceGuard** | Validates workspace membership and sets Row-Level Security (RLS) context | Workspace-scoped resources |
| **PermissionGuard** | Enforces role-based access control | Admin operations |
### Workspace Roles
- **OWNER** - Full control over workspace
- **ADMIN** - Administrative functions (can delete content, manage members)
- **MEMBER** - Standard access (create/edit content)
- **GUEST** - Read-only access
### Permission Levels
Used with `@RequirePermission()` decorator:
```typescript
Permission.WORKSPACE_OWNER; // Requires OWNER role
Permission.WORKSPACE_ADMIN; // Requires ADMIN or OWNER
Permission.WORKSPACE_MEMBER; // Requires MEMBER, ADMIN, or OWNER
Permission.WORKSPACE_ANY; // Any authenticated member including GUEST
```
### Providing Workspace Context
Workspace ID can be provided via:
1. **Header**: `X-Workspace-Id: <workspace-id>` (highest priority)
2. **URL Parameter**: `:workspaceId`
3. **Request Body**: `workspaceId` field
### Example: Protected Controller
```typescript
@Controller("tasks")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class TasksController {
@Post()
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create(@Body() dto: CreateTaskDto, @Workspace() workspaceId: string) {
// workspaceId is verified and RLS context is set
}
}
```
## Environment Variables
| Variable | Description | Default |
| --------------------- | ----------------------------------------- | ----------------------- |
| `PORT` | API server port | `3001` |
| `DATABASE_URL` | PostgreSQL connection string | Required |
| `NODE_ENV` | Environment (`development`, `production`) | - |
| `NEXT_PUBLIC_APP_URL` | Frontend application URL (for CORS) | `http://localhost:3000` |
| `WEB_URL` | WebSocket CORS origin | `http://localhost:3000` |
## Running Locally
### Prerequisites
- Node.js 18+
- PostgreSQL database
- pnpm workspace (part of Mosaic Stack monorepo)
### Setup
1. **Install dependencies:**
```bash
pnpm install
```
2. **Set up environment variables:**
```bash
cp .env.example .env # If available
# Edit .env with your DATABASE_URL
```
3. **Generate Prisma client:**
```bash
pnpm prisma:generate
```
4. **Run database migrations:**
```bash
pnpm prisma:migrate
```
5. **Seed the database (optional):**
```bash
pnpm prisma:seed
```
### Development
```bash
pnpm dev
```
The API will start on `http://localhost:3001`
### Production Build
```bash
pnpm build
pnpm start:prod
```
### Database Management
```bash
# Open Prisma Studio
pnpm prisma:studio
# Reset database (dev only)
pnpm prisma:reset
# Run migrations in production
pnpm prisma:migrate:prod
```
## API Documentation
The API does not currently include Swagger/OpenAPI documentation. Instead:
- **Controller files** contain detailed JSDoc comments describing each endpoint
- **DTO classes** define request/response schemas with class-validator decorators
- Refer to the controller source files in `src/` for endpoint details
### Example: Reading an Endpoint
```typescript
// src/tasks/tasks.controller.ts
/**
* POST /api/tasks
* Create a new task
* Requires: MEMBER role or higher
*/
@Post()
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create(@Body() createTaskDto: CreateTaskDto, @Workspace() workspaceId: string) {
return this.tasksService.create(workspaceId, user.id, createTaskDto);
}
```
## WebSocket Support
The API provides real-time updates via WebSocket. Clients receive notifications for:
- `task:created` - New task created
- `task:updated` - Task modified
- `task:deleted` - Task removed
- `event:created` - New event created
- `event:updated` - Event modified
- `event:deleted` - Event removed
- `project:updated` - Project modified
Clients join workspace-specific rooms for scoped updates.
## Testing
```bash
# Run unit tests
pnpm test
# Run tests with coverage
pnpm test:coverage
# Run e2e tests
pnpm test:e2e
# Watch mode
pnpm test:watch
```
## Project Structure
```
src/
├── activity/ # Activity logging
├── auth/ # Authentication (BetterAuth config, guards)
├── common/ # Shared decorators and guards
├── database/ # Database module
├── domains/ # Domain management
├── events/ # Event management
├── filters/ # Global exception filters
├── ideas/ # Idea capture and management
├── knowledge/ # Knowledge base (entries, tags, markdown)
├── layouts/ # Dashboard layouts
├── lib/ # Utility functions
├── ollama/ # LLM integration
├── personalities/ # AI personality configurations
├── prisma/ # Prisma service
├── projects/ # Project management
├── tasks/ # Task management
├── users/ # User preferences
├── widgets/ # Dashboard widgets
├── websocket/ # WebSocket gateway
├── app.controller.ts # Root controller (health check)
├── app.module.ts # Root module
└── main.ts # Application bootstrap
```
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
set -e
echo "Running database migrations..."
./node_modules/.bin/prisma migrate deploy --schema ./prisma/schema.prisma
echo "Starting application..."
exec node dist/main.js
-16
View File
@@ -1,16 +0,0 @@
import nestjsConfig from "@mosaic/config/eslint/nestjs";
export default [
...nestjsConfig,
{
languageOptions: {
parserOptions: {
project: ["./tsconfig.json"],
tsconfigRootDir: import.meta.dirname,
},
},
},
{
ignores: ["dist/**", "node_modules/**", "**/*.test.ts", "**/*.spec.ts"],
},
];
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-106
View File
@@ -1,106 +0,0 @@
{
"name": "@mosaic/api",
"version": "0.0.20",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config ./vitest.e2e.config.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"prisma:seed": "prisma db seed",
"prisma:reset": "prisma migrate reset",
"migrate:encrypt-llm-keys": "tsx scripts/encrypt-llm-keys.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.72.1",
"@mosaic/shared": "workspace:*",
"@mosaicstack/telemetry-client": "^0.1.1",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.12",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.12",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.1.12",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.12",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.56.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.44.0",
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-node": "^0.56.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"@prisma/client": "^6.19.2",
"@types/marked": "^6.0.0",
"@types/multer": "^2.0.0",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.17",
"bullmq": "^5.67.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"discord.js": "^14.25.1",
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"ioredis": "^5.9.2",
"jose": "^6.1.3",
"marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.3",
"marked-highlight": "^2.2.3",
"matrix-bot-sdk": "^0.8.0",
"node-pty": "^1.0.0",
"ollama": "^0.6.3",
"openai": "^6.17.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-html": "^2.17.0",
"slugify": "^1.6.6",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@better-auth/cli": "^1.4.17",
"@mosaic/config": "workspace:*",
"@nestjs/cli": "^11.0.6",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.1.12",
"@opentelemetry/context-async-hooks": "^2.5.0",
"@swc/core": "^1.10.18",
"@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.1",
"@types/highlight.js": "^10.1.0",
"@types/node": "^22.13.4",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.3",
"@vitest/coverage-v8": "^4.0.18",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"prisma": "^6.19.2",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.8.2",
"unplugin-swc": "^1.5.2",
"vitest": "^4.0.18"
}
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "prisma/config";
export default defineConfig({
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -1,261 +0,0 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "vector";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('NOT_STARTED', 'IN_PROGRESS', 'PAUSED', 'COMPLETED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('PLANNING', 'ACTIVE', 'PAUSED', 'COMPLETED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "WorkspaceMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'GUEST');
-- CreateEnum
CREATE TYPE "ActivityAction" AS ENUM ('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'ASSIGNED', 'COMMENTED');
-- CreateEnum
CREATE TYPE "EntityType" AS ENUM ('TASK', 'EVENT', 'PROJECT', 'WORKSPACE', 'USER');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"auth_provider_id" TEXT,
"preferences" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspaces" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"owner_id" UUID NOT NULL,
"settings" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspace_members" (
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"role" "WorkspaceMemberRole" NOT NULL DEFAULT 'MEMBER',
"joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workspace_members_pkey" PRIMARY KEY ("workspace_id","user_id")
);
-- CreateTable
CREATE TABLE "tasks" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'NOT_STARTED',
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
"due_date" TIMESTAMPTZ,
"assignee_id" UUID,
"creator_id" UUID NOT NULL,
"project_id" UUID,
"parent_id" UUID,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "events" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"start_time" TIMESTAMPTZ NOT NULL,
"end_time" TIMESTAMPTZ,
"all_day" BOOLEAN NOT NULL DEFAULT false,
"location" TEXT,
"recurrence" JSONB,
"creator_id" UUID NOT NULL,
"project_id" UUID,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'PLANNING',
"start_date" DATE,
"end_date" DATE,
"creator_id" UUID NOT NULL,
"color" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "activity_logs" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"action" "ActivityAction" NOT NULL,
"entity_type" "EntityType" NOT NULL,
"entity_id" UUID NOT NULL,
"details" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "activity_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "memory_embeddings" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"content" TEXT NOT NULL,
"embedding" vector(1536),
"entity_type" "EntityType",
"entity_id" UUID,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "memory_embeddings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_auth_provider_id_key" ON "users"("auth_provider_id");
-- CreateIndex
CREATE INDEX "workspaces_owner_id_idx" ON "workspaces"("owner_id");
-- CreateIndex
CREATE INDEX "workspace_members_user_id_idx" ON "workspace_members"("user_id");
-- CreateIndex
CREATE INDEX "tasks_workspace_id_idx" ON "tasks"("workspace_id");
-- CreateIndex
CREATE INDEX "tasks_workspace_id_status_idx" ON "tasks"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "tasks_workspace_id_due_date_idx" ON "tasks"("workspace_id", "due_date");
-- CreateIndex
CREATE INDEX "tasks_assignee_id_idx" ON "tasks"("assignee_id");
-- CreateIndex
CREATE INDEX "tasks_project_id_idx" ON "tasks"("project_id");
-- CreateIndex
CREATE INDEX "tasks_parent_id_idx" ON "tasks"("parent_id");
-- CreateIndex
CREATE INDEX "events_workspace_id_idx" ON "events"("workspace_id");
-- CreateIndex
CREATE INDEX "events_workspace_id_start_time_idx" ON "events"("workspace_id", "start_time");
-- CreateIndex
CREATE INDEX "events_creator_id_idx" ON "events"("creator_id");
-- CreateIndex
CREATE INDEX "events_project_id_idx" ON "events"("project_id");
-- CreateIndex
CREATE INDEX "projects_workspace_id_idx" ON "projects"("workspace_id");
-- CreateIndex
CREATE INDEX "projects_workspace_id_status_idx" ON "projects"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "projects_creator_id_idx" ON "projects"("creator_id");
-- CreateIndex
CREATE INDEX "activity_logs_workspace_id_idx" ON "activity_logs"("workspace_id");
-- CreateIndex
CREATE INDEX "activity_logs_workspace_id_created_at_idx" ON "activity_logs"("workspace_id", "created_at");
-- CreateIndex
CREATE INDEX "activity_logs_entity_type_entity_id_idx" ON "activity_logs"("entity_type", "entity_id");
-- CreateIndex
CREATE INDEX "activity_logs_user_id_idx" ON "activity_logs"("user_id");
-- CreateIndex
CREATE INDEX "memory_embeddings_workspace_id_idx" ON "memory_embeddings"("workspace_id");
-- AddForeignKey
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "memory_embeddings" ADD CONSTRAINT "memory_embeddings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,8 +0,0 @@
-- Add HNSW index for fast vector similarity search on memory_embeddings table
-- Using cosine distance operator for semantic similarity
-- Parameters: m=16 (max connections per layer), ef_construction=64 (build quality)
CREATE INDEX IF NOT EXISTS memory_embeddings_embedding_idx
ON memory_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
@@ -1,92 +0,0 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ActivityAction" ADD VALUE 'LOGIN';
ALTER TYPE "ActivityAction" ADD VALUE 'LOGOUT';
ALTER TYPE "ActivityAction" ADD VALUE 'PASSWORD_RESET';
ALTER TYPE "ActivityAction" ADD VALUE 'EMAIL_VERIFIED';
-- AlterTable
ALTER TABLE "activity_logs" ADD COLUMN "ip_address" TEXT,
ADD COLUMN "user_agent" TEXT;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "image" TEXT;
-- CreateTable
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"ip_address" TEXT,
"user_agent" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"account_id" TEXT NOT NULL,
"provider_id" TEXT NOT NULL,
"access_token" TEXT,
"refresh_token" TEXT,
"id_token" TEXT,
"access_token_expires_at" TIMESTAMPTZ,
"refresh_token_expires_at" TIMESTAMPTZ,
"scope" TEXT,
"password" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verifications" (
"id" UUID NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "verifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "sessions_token_idx" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "accounts_user_id_idx" ON "accounts"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_id_account_id_key" ON "accounts"("provider_id", "account_id");
-- CreateIndex
CREATE INDEX "verifications_identifier_idx" ON "verifications"("identifier");
-- CreateIndex
CREATE INDEX "activity_logs_action_idx" ON "activity_logs"("action");
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,286 +0,0 @@
-- CreateEnum
CREATE TYPE "IdeaStatus" AS ENUM ('CAPTURED', 'PROCESSING', 'ACTIONABLE', 'ARCHIVED', 'DISCARDED');
-- CreateEnum
CREATE TYPE "RelationshipType" AS ENUM ('BLOCKS', 'BLOCKED_BY', 'DEPENDS_ON', 'PARENT_OF', 'CHILD_OF', 'RELATED_TO', 'DUPLICATE_OF', 'SUPERSEDES', 'PART_OF');
-- CreateEnum
CREATE TYPE "AgentStatus" AS ENUM ('IDLE', 'WORKING', 'WAITING', 'ERROR', 'TERMINATED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "EntityType" ADD VALUE 'IDEA';
ALTER TYPE "EntityType" ADD VALUE 'DOMAIN';
-- DropIndex
DROP INDEX "memory_embeddings_embedding_idx";
-- AlterTable
ALTER TABLE "events" ADD COLUMN "domain_id" UUID;
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "domain_id" UUID;
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "domain_id" UUID;
-- CreateTable
CREATE TABLE "domains" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"color" TEXT,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "domains_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ideas" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"domain_id" UUID,
"project_id" UUID,
"title" TEXT,
"content" TEXT NOT NULL,
"status" "IdeaStatus" NOT NULL DEFAULT 'CAPTURED',
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
"category" TEXT,
"tags" TEXT[],
"metadata" JSONB NOT NULL DEFAULT '{}',
"embedding" vector(1536),
"creator_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "ideas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "relationships" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"source_type" "EntityType" NOT NULL,
"source_id" UUID NOT NULL,
"target_type" "EntityType" NOT NULL,
"target_id" UUID NOT NULL,
"relationship" "RelationshipType" NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"notes" TEXT,
"creator_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "relationships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agents" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"name" TEXT,
"model" TEXT,
"role" TEXT,
"status" "AgentStatus" NOT NULL DEFAULT 'IDLE',
"current_task" TEXT,
"metrics" JSONB NOT NULL DEFAULT '{"totalTasks": 0, "successfulTasks": 0, "failedTasks": 0, "avgResponseTimeMs": 0}',
"last_heartbeat" TIMESTAMPTZ,
"error_count" INTEGER NOT NULL DEFAULT 0,
"last_error" TEXT,
"fired_count" INTEGER NOT NULL DEFAULT 0,
"fire_history" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"terminated_at" TIMESTAMPTZ,
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_sessions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"agent_id" UUID,
"session_key" TEXT NOT NULL,
"label" TEXT,
"channel" TEXT,
"context_summary" TEXT,
"message_count" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_message_at" TIMESTAMPTZ,
"ended_at" TIMESTAMPTZ,
"metadata" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "agent_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "widget_definitions" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"description" TEXT,
"component" TEXT NOT NULL,
"default_width" INTEGER NOT NULL DEFAULT 1,
"default_height" INTEGER NOT NULL DEFAULT 1,
"min_width" INTEGER NOT NULL DEFAULT 1,
"min_height" INTEGER NOT NULL DEFAULT 1,
"max_width" INTEGER,
"max_height" INTEGER,
"config_schema" JSONB NOT NULL DEFAULT '{}',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "widget_definitions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_layouts" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"layout" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "user_layouts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "domains_workspace_id_idx" ON "domains"("workspace_id");
-- CreateIndex
CREATE UNIQUE INDEX "domains_workspace_id_slug_key" ON "domains"("workspace_id", "slug");
-- CreateIndex
CREATE INDEX "ideas_workspace_id_idx" ON "ideas"("workspace_id");
-- CreateIndex
CREATE INDEX "ideas_workspace_id_status_idx" ON "ideas"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "ideas_domain_id_idx" ON "ideas"("domain_id");
-- CreateIndex
CREATE INDEX "ideas_project_id_idx" ON "ideas"("project_id");
-- CreateIndex
CREATE INDEX "ideas_creator_id_idx" ON "ideas"("creator_id");
-- CreateIndex
CREATE INDEX "relationships_source_type_source_id_idx" ON "relationships"("source_type", "source_id");
-- CreateIndex
CREATE INDEX "relationships_target_type_target_id_idx" ON "relationships"("target_type", "target_id");
-- CreateIndex
CREATE INDEX "relationships_relationship_idx" ON "relationships"("relationship");
-- CreateIndex
CREATE UNIQUE INDEX "relationships_workspace_id_source_type_source_id_target_typ_key" ON "relationships"("workspace_id", "source_type", "source_id", "target_type", "target_id", "relationship");
-- CreateIndex
CREATE INDEX "agents_workspace_id_idx" ON "agents"("workspace_id");
-- CreateIndex
CREATE INDEX "agents_status_idx" ON "agents"("status");
-- CreateIndex
CREATE UNIQUE INDEX "agents_workspace_id_agent_id_key" ON "agents"("workspace_id", "agent_id");
-- CreateIndex
CREATE INDEX "agent_sessions_workspace_id_idx" ON "agent_sessions"("workspace_id");
-- CreateIndex
CREATE INDEX "agent_sessions_user_id_idx" ON "agent_sessions"("user_id");
-- CreateIndex
CREATE INDEX "agent_sessions_agent_id_idx" ON "agent_sessions"("agent_id");
-- CreateIndex
CREATE INDEX "agent_sessions_is_active_idx" ON "agent_sessions"("is_active");
-- CreateIndex
CREATE UNIQUE INDEX "agent_sessions_workspace_id_session_key_key" ON "agent_sessions"("workspace_id", "session_key");
-- CreateIndex
CREATE UNIQUE INDEX "widget_definitions_name_key" ON "widget_definitions"("name");
-- CreateIndex
CREATE INDEX "user_layouts_user_id_idx" ON "user_layouts"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_layouts_workspace_id_user_id_name_key" ON "user_layouts"("workspace_id", "user_id", "name");
-- CreateIndex
CREATE INDEX "events_domain_id_idx" ON "events"("domain_id");
-- CreateIndex
CREATE INDEX "projects_domain_id_idx" ON "projects"("domain_id");
-- CreateIndex
CREATE INDEX "tasks_domain_id_idx" ON "tasks"("domain_id");
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "domains" ADD CONSTRAINT "domains_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agents" ADD CONSTRAINT "agents_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,158 +0,0 @@
-- CreateEnum
CREATE TYPE "EntryStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'WORKSPACE', 'PUBLIC');
-- AlterTable
ALTER TABLE "user_layouts" ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';
-- CreateTable
CREATE TABLE "knowledge_entries" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"content_html" TEXT,
"summary" TEXT,
"status" "EntryStatus" NOT NULL DEFAULT 'DRAFT',
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"created_by" UUID NOT NULL,
"updated_by" UUID NOT NULL,
CONSTRAINT "knowledge_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_entry_versions" (
"id" UUID NOT NULL,
"entry_id" UUID NOT NULL,
"version" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"summary" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID NOT NULL,
"change_note" TEXT,
CONSTRAINT "knowledge_entry_versions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_links" (
"id" UUID NOT NULL,
"source_id" UUID NOT NULL,
"target_id" UUID NOT NULL,
"link_text" TEXT NOT NULL,
"context" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "knowledge_links_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_tags" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"color" TEXT,
"description" TEXT,
CONSTRAINT "knowledge_tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_entry_tags" (
"entry_id" UUID NOT NULL,
"tag_id" UUID NOT NULL,
CONSTRAINT "knowledge_entry_tags_pkey" PRIMARY KEY ("entry_id","tag_id")
);
-- CreateTable
CREATE TABLE "knowledge_embeddings" (
"id" UUID NOT NULL,
"entry_id" UUID NOT NULL,
"embedding" vector(1536) NOT NULL,
"model" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "knowledge_embeddings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "knowledge_entries_workspace_id_status_idx" ON "knowledge_entries"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "knowledge_entries_workspace_id_updated_at_idx" ON "knowledge_entries"("workspace_id", "updated_at");
-- CreateIndex
CREATE INDEX "knowledge_entries_created_by_idx" ON "knowledge_entries"("created_by");
-- CreateIndex
CREATE INDEX "knowledge_entries_updated_by_idx" ON "knowledge_entries"("updated_by");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_entries_workspace_id_slug_key" ON "knowledge_entries"("workspace_id", "slug");
-- CreateIndex
CREATE INDEX "knowledge_entry_versions_entry_id_version_idx" ON "knowledge_entry_versions"("entry_id", "version");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_entry_versions_entry_id_version_key" ON "knowledge_entry_versions"("entry_id", "version");
-- CreateIndex
CREATE INDEX "knowledge_links_source_id_idx" ON "knowledge_links"("source_id");
-- CreateIndex
CREATE INDEX "knowledge_links_target_id_idx" ON "knowledge_links"("target_id");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_links_source_id_target_id_key" ON "knowledge_links"("source_id", "target_id");
-- CreateIndex
CREATE INDEX "knowledge_tags_workspace_id_idx" ON "knowledge_tags"("workspace_id");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_tags_workspace_id_slug_key" ON "knowledge_tags"("workspace_id", "slug");
-- CreateIndex
CREATE INDEX "knowledge_entry_tags_entry_id_idx" ON "knowledge_entry_tags"("entry_id");
-- CreateIndex
CREATE INDEX "knowledge_entry_tags_tag_id_idx" ON "knowledge_entry_tags"("tag_id");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_embeddings_entry_id_key" ON "knowledge_embeddings"("entry_id");
-- CreateIndex
CREATE INDEX "knowledge_embeddings_entry_id_idx" ON "knowledge_embeddings"("entry_id");
-- AddForeignKey
ALTER TABLE "knowledge_entries" ADD CONSTRAINT "knowledge_entries_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_entry_versions" ADD CONSTRAINT "knowledge_entry_versions_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_links" ADD CONSTRAINT "knowledge_links_source_id_fkey" FOREIGN KEY ("source_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_links" ADD CONSTRAINT "knowledge_links_target_id_fkey" FOREIGN KEY ("target_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_tags" ADD CONSTRAINT "knowledge_tags_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_entry_tags" ADD CONSTRAINT "knowledge_entry_tags_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_entry_tags" ADD CONSTRAINT "knowledge_entry_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "knowledge_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_embeddings" ADD CONSTRAINT "knowledge_embeddings_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,40 +0,0 @@
-- CreateEnum
CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
-- CreateTable
CREATE TABLE "teams" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "team_members" (
"team_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"role" "TeamMemberRole" NOT NULL DEFAULT 'MEMBER',
"joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "team_members_pkey" PRIMARY KEY ("team_id","user_id")
);
-- CreateIndex
CREATE INDEX "teams_workspace_id_idx" ON "teams"("workspace_id");
-- CreateIndex
CREATE INDEX "team_members_user_id_idx" ON "team_members"("user_id");
-- AddForeignKey
ALTER TABLE "teams" ADD CONSTRAINT "teams_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,319 +0,0 @@
-- Row-Level Security (RLS) for Multi-Tenant Isolation
-- This migration enables RLS on all tenant-scoped tables and creates policies
-- to ensure users can only access data within their authorized workspaces.
-- =============================================================================
-- ENABLE RLS ON TENANT-SCOPED TABLES
-- =============================================================================
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE activity_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE memory_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE domains ENABLE ROW LEVEL SECURITY;
ALTER TABLE ideas ENABLE ROW LEVEL SECURITY;
ALTER TABLE relationships ENABLE ROW LEVEL SECURITY;
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_layouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entry_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_links ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entry_versions ENABLE ROW LEVEL SECURITY;
-- =============================================================================
-- HELPER FUNCTION: Check if user is workspace member
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_member(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- HELPER FUNCTION: Check if user is workspace owner/admin
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
AND role IN ('OWNER', 'ADMIN')
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- HELPER FUNCTION: Get current user ID from session variable
-- =============================================================================
-- Usage in API: SET LOCAL app.current_user_id = 'user-uuid';
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_user_id', TRUE), '')::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
-- =============================================================================
-- WORKSPACES: Users can only see workspaces they're members of
-- =============================================================================
CREATE POLICY workspace_member_access ON workspaces
FOR ALL
USING (
id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = current_user_id()
)
);
-- =============================================================================
-- WORKSPACE_MEMBERS: Users can see members of their workspaces
-- =============================================================================
CREATE POLICY workspace_members_access ON workspace_members
FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = current_user_id()
)
);
-- =============================================================================
-- TEAMS: Users can see teams in their workspaces
-- =============================================================================
CREATE POLICY teams_workspace_access ON teams
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- TEAM_MEMBERS: Users can see team members in their workspaces
-- =============================================================================
CREATE POLICY team_members_access ON team_members
FOR ALL
USING (
team_id IN (
SELECT id FROM teams
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- TASKS: Users can only see tasks in their workspaces
-- =============================================================================
CREATE POLICY tasks_workspace_access ON tasks
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- EVENTS: Users can only see events in their workspaces
-- =============================================================================
CREATE POLICY events_workspace_access ON events
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- PROJECTS: Users can only see projects in their workspaces
-- =============================================================================
CREATE POLICY projects_workspace_access ON projects
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- ACTIVITY_LOGS: Users can only see activity in their workspaces
-- =============================================================================
CREATE POLICY activity_logs_workspace_access ON activity_logs
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- MEMORY_EMBEDDINGS: Users can only see embeddings in their workspaces
-- =============================================================================
CREATE POLICY memory_embeddings_workspace_access ON memory_embeddings
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- DOMAINS: Users can only see domains in their workspaces
-- =============================================================================
CREATE POLICY domains_workspace_access ON domains
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- IDEAS: Users can only see ideas in their workspaces
-- =============================================================================
CREATE POLICY ideas_workspace_access ON ideas
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- RELATIONSHIPS: Users can only see relationships in their workspaces
-- =============================================================================
CREATE POLICY relationships_workspace_access ON relationships
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- AGENTS: Users can only see agents in their workspaces
-- =============================================================================
CREATE POLICY agents_workspace_access ON agents
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- AGENT_SESSIONS: Users can only see agent sessions in their workspaces
-- =============================================================================
CREATE POLICY agent_sessions_workspace_access ON agent_sessions
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- USER_LAYOUTS: Users can only see their own layouts in their workspaces
-- =============================================================================
CREATE POLICY user_layouts_workspace_access ON user_layouts
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
AND user_id = current_user_id()
);
-- =============================================================================
-- KNOWLEDGE_ENTRIES: Users can only see entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entries_workspace_access ON knowledge_entries
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- KNOWLEDGE_TAGS: Users can only see tags in their workspaces
-- =============================================================================
CREATE POLICY knowledge_tags_workspace_access ON knowledge_tags
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- KNOWLEDGE_ENTRY_TAGS: Users can see tags for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entry_tags_access ON knowledge_entry_tags
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_LINKS: Users can see links between entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_links_access ON knowledge_links
FOR ALL
USING (
source_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_EMBEDDINGS: Users can see embeddings for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_embeddings_access ON knowledge_embeddings
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_ENTRY_VERSIONS: Users can see versions for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entry_versions_access ON knowledge_entry_versions
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- GRANT USAGE TO APPLICATION ROLE
-- =============================================================================
-- The application should connect with a role that has appropriate permissions.
-- By default, we assume the owner of the database has full access.
-- In production, create a dedicated role with limited permissions.
-- Example (uncomment and customize for production):
-- GRANT USAGE ON SCHEMA public TO mosaic_app;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mosaic_app;
-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO mosaic_app;
@@ -1,18 +0,0 @@
-- CreateTable
CREATE TABLE "user_preferences" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"theme" TEXT NOT NULL DEFAULT 'system',
"locale" TEXT NOT NULL DEFAULT 'en',
"timezone" TEXT,
"settings" JSONB NOT NULL DEFAULT '{}',
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_preferences_user_id_key" ON "user_preferences"("user_id");
-- AddForeignKey
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,47 +0,0 @@
-- CreateEnum
CREATE TYPE "AgentTaskStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "AgentTaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateTable
CREATE TABLE "agent_tasks" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "AgentTaskStatus" NOT NULL DEFAULT 'PENDING',
"priority" "AgentTaskPriority" NOT NULL DEFAULT 'MEDIUM',
"agent_type" TEXT NOT NULL,
"agent_config" JSONB NOT NULL DEFAULT '{}',
"result" JSONB,
"error" TEXT,
"created_by_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "agent_tasks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "agent_tasks_workspace_id_idx" ON "agent_tasks"("workspace_id");
-- CreateIndex
CREATE INDEX "agent_tasks_workspace_id_status_idx" ON "agent_tasks"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "agent_tasks_workspace_id_priority_idx" ON "agent_tasks"("workspace_id", "priority");
-- CreateIndex
CREATE INDEX "agent_tasks_created_by_id_idx" ON "agent_tasks"("created_by_id");
-- CreateIndex
CREATE UNIQUE INDEX "agent_tasks_id_workspace_id_key" ON "agent_tasks"("id", "workspace_id");
-- AddForeignKey
ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,31 +0,0 @@
-- CreateEnum
CREATE TYPE "FormalityLevel" AS ENUM ('VERY_CASUAL', 'CASUAL', 'NEUTRAL', 'FORMAL', 'VERY_FORMAL');
-- CreateTable
CREATE TABLE "personalities" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"tone" TEXT NOT NULL,
"formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL',
"system_prompt_template" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "personalities_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "personalities_workspace_id_idx" ON "personalities"("workspace_id");
-- CreateIndex
CREATE INDEX "personalities_workspace_id_is_default_idx" ON "personalities"("workspace_id", "is_default");
-- CreateIndex
CREATE UNIQUE INDEX "personalities_workspace_id_name_key" ON "personalities"("workspace_id", "name");
-- AddForeignKey
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,41 +0,0 @@
/*
Warnings:
- You are about to drop the `personalities` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `display_text` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
- Added the required column `position_end` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
- Added the required column `position_start` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "personalities" DROP CONSTRAINT "personalities_workspace_id_fkey";
-- DropIndex
DROP INDEX "knowledge_links_source_id_target_id_key";
-- AlterTable: Add new columns with temporary defaults for existing records
ALTER TABLE "knowledge_links"
ADD COLUMN "display_text" TEXT DEFAULT '',
ADD COLUMN "position_end" INTEGER DEFAULT 0,
ADD COLUMN "position_start" INTEGER DEFAULT 0,
ADD COLUMN "resolved" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "target_id" DROP NOT NULL;
-- Update existing records: set display_text to link_text and resolved to true if target exists
UPDATE "knowledge_links" SET "display_text" = "link_text" WHERE "display_text" = '';
UPDATE "knowledge_links" SET "resolved" = true WHERE "target_id" IS NOT NULL;
-- Remove defaults for new records
ALTER TABLE "knowledge_links"
ALTER COLUMN "display_text" DROP DEFAULT,
ALTER COLUMN "position_end" DROP DEFAULT,
ALTER COLUMN "position_start" DROP DEFAULT;
-- DropTable
DROP TABLE "personalities";
-- DropEnum
DROP TYPE "FormalityLevel";
-- CreateIndex
CREATE INDEX "knowledge_links_source_id_resolved_idx" ON "knowledge_links"("source_id", "resolved");
@@ -1,8 +0,0 @@
-- Add HNSW index for fast vector similarity search on knowledge_embeddings table
-- Using cosine distance operator for semantic similarity
-- Parameters: m=16 (max connections per layer), ef_construction=64 (build quality)
CREATE INDEX IF NOT EXISTS knowledge_embeddings_embedding_idx
ON knowledge_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
@@ -1,29 +0,0 @@
-- CreateTable
CREATE TABLE "llm_provider_instances" (
"id" UUID NOT NULL,
"provider_type" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"user_id" UUID,
"config" JSONB NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "llm_provider_instances_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "llm_provider_instances_user_id_idx" ON "llm_provider_instances"("user_id");
-- CreateIndex
CREATE INDEX "llm_provider_instances_provider_type_idx" ON "llm_provider_instances"("provider_type");
-- CreateIndex
CREATE INDEX "llm_provider_instances_is_default_idx" ON "llm_provider_instances"("is_default");
-- CreateIndex
CREATE INDEX "llm_provider_instances_is_enabled_idx" ON "llm_provider_instances"("is_enabled");
-- AddForeignKey
ALTER TABLE "llm_provider_instances" ADD CONSTRAINT "llm_provider_instances_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,112 +0,0 @@
-- CreateEnum
CREATE TYPE "RunnerJobStatus" AS ENUM ('PENDING', 'QUEUED', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "JobStepPhase" AS ENUM ('SETUP', 'EXECUTION', 'VALIDATION', 'CLEANUP');
-- CreateEnum
CREATE TYPE "JobStepType" AS ENUM ('COMMAND', 'AI_ACTION', 'GATE', 'ARTIFACT');
-- CreateEnum
CREATE TYPE "JobStepStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'SKIPPED');
-- CreateTable
CREATE TABLE "runner_jobs" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_task_id" UUID,
"type" TEXT NOT NULL,
"status" "RunnerJobStatus" NOT NULL DEFAULT 'PENDING',
"priority" INTEGER NOT NULL,
"progress_percent" INTEGER NOT NULL DEFAULT 0,
"result" JSONB,
"error" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "runner_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "job_steps" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"ordinal" INTEGER NOT NULL,
"phase" "JobStepPhase" NOT NULL,
"name" TEXT NOT NULL,
"type" "JobStepType" NOT NULL,
"status" "JobStepStatus" NOT NULL DEFAULT 'PENDING',
"output" TEXT,
"tokens_input" INTEGER,
"tokens_output" INTEGER,
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
"duration_ms" INTEGER,
CONSTRAINT "job_steps_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "job_events" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"step_id" UUID,
"type" TEXT NOT NULL,
"timestamp" TIMESTAMPTZ NOT NULL,
"actor" TEXT NOT NULL,
"payload" JSONB NOT NULL,
CONSTRAINT "job_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "runner_jobs_id_workspace_id_key" ON "runner_jobs"("id", "workspace_id");
-- CreateIndex
CREATE INDEX "runner_jobs_workspace_id_idx" ON "runner_jobs"("workspace_id");
-- CreateIndex
CREATE INDEX "runner_jobs_workspace_id_status_idx" ON "runner_jobs"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "runner_jobs_agent_task_id_idx" ON "runner_jobs"("agent_task_id");
-- CreateIndex
CREATE INDEX "runner_jobs_priority_idx" ON "runner_jobs"("priority");
-- CreateIndex
CREATE INDEX "job_steps_job_id_idx" ON "job_steps"("job_id");
-- CreateIndex
CREATE INDEX "job_steps_job_id_ordinal_idx" ON "job_steps"("job_id", "ordinal");
-- CreateIndex
CREATE INDEX "job_steps_status_idx" ON "job_steps"("status");
-- CreateIndex
CREATE INDEX "job_events_job_id_idx" ON "job_events"("job_id");
-- CreateIndex
CREATE INDEX "job_events_step_id_idx" ON "job_events"("step_id");
-- CreateIndex
CREATE INDEX "job_events_timestamp_idx" ON "job_events"("timestamp");
-- CreateIndex
CREATE INDEX "job_events_type_idx" ON "job_events"("type");
-- AddForeignKey
ALTER TABLE "runner_jobs" ADD CONSTRAINT "runner_jobs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "runner_jobs" ADD CONSTRAINT "runner_jobs_agent_task_id_fkey" FOREIGN KEY ("agent_task_id") REFERENCES "agent_tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_steps" ADD CONSTRAINT "job_steps_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "runner_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_events" ADD CONSTRAINT "job_events_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "runner_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_events" ADD CONSTRAINT "job_events_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "job_steps"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- CreateIndex
CREATE INDEX "job_events_job_id_timestamp_idx" ON "job_events"("job_id", "timestamp");
@@ -1,36 +0,0 @@
-- Add tsvector column for full-text search on knowledge_entries
-- Weighted fields: title (A), summary (B), content (C)
-- Step 1: Add the search_vector column
ALTER TABLE "knowledge_entries"
ADD COLUMN "search_vector" tsvector;
-- Step 2: Create GIN index for fast full-text search
CREATE INDEX "knowledge_entries_search_vector_idx"
ON "knowledge_entries"
USING gin("search_vector");
-- Step 3: Create function to update search_vector
CREATE OR REPLACE FUNCTION knowledge_entries_search_vector_update()
RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
-- Step 4: Create trigger to automatically update search_vector on insert/update
CREATE TRIGGER knowledge_entries_search_vector_trigger
BEFORE INSERT OR UPDATE ON "knowledge_entries"
FOR EACH ROW
EXECUTE FUNCTION knowledge_entries_search_vector_update();
-- Step 5: Populate search_vector for existing entries
UPDATE "knowledge_entries"
SET search_vector =
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(summary, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(content, '')), 'C');
@@ -1,118 +0,0 @@
-- CreateEnum
CREATE TYPE "FederationConnectionStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED', 'DISCONNECTED');
-- CreateEnum
CREATE TYPE "FederationMessageType" AS ENUM ('QUERY', 'COMMAND', 'EVENT');
-- CreateEnum
CREATE TYPE "FederationMessageStatus" AS ENUM ('PENDING', 'DELIVERED', 'FAILED', 'TIMEOUT');
-- CreateTable
CREATE TABLE "federation_connections" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"remote_instance_id" TEXT NOT NULL,
"remote_url" TEXT NOT NULL,
"remote_public_key" TEXT NOT NULL,
"remote_capabilities" JSONB NOT NULL DEFAULT '{}',
"status" "FederationConnectionStatus" NOT NULL DEFAULT 'PENDING',
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"connected_at" TIMESTAMPTZ,
"disconnected_at" TIMESTAMPTZ,
CONSTRAINT "federation_connections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "federated_identities" (
"id" UUID NOT NULL,
"local_user_id" UUID NOT NULL,
"remote_user_id" TEXT NOT NULL,
"remote_instance_id" TEXT NOT NULL,
"oidc_subject" TEXT NOT NULL,
"email" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "federated_identities_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "federation_messages" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"connection_id" UUID NOT NULL,
"message_type" "FederationMessageType" NOT NULL,
"message_id" TEXT NOT NULL,
"correlation_id" TEXT,
"query" TEXT,
"command_type" TEXT,
"event_type" TEXT,
"payload" JSONB DEFAULT '{}',
"response" JSONB DEFAULT '{}',
"status" "FederationMessageStatus" NOT NULL DEFAULT 'PENDING',
"error" TEXT,
"signature" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"delivered_at" TIMESTAMPTZ,
CONSTRAINT "federation_messages_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "federation_connections_workspace_id_remote_instance_id_key" ON "federation_connections"("workspace_id", "remote_instance_id");
-- CreateIndex
CREATE INDEX "federation_connections_workspace_id_idx" ON "federation_connections"("workspace_id");
-- CreateIndex
CREATE INDEX "federation_connections_workspace_id_status_idx" ON "federation_connections"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "federation_connections_remote_instance_id_idx" ON "federation_connections"("remote_instance_id");
-- CreateIndex
CREATE UNIQUE INDEX "federated_identities_local_user_id_remote_instance_id_key" ON "federated_identities"("local_user_id", "remote_instance_id");
-- CreateIndex
CREATE INDEX "federated_identities_local_user_id_idx" ON "federated_identities"("local_user_id");
-- CreateIndex
CREATE INDEX "federated_identities_remote_instance_id_idx" ON "federated_identities"("remote_instance_id");
-- CreateIndex
CREATE INDEX "federated_identities_oidc_subject_idx" ON "federated_identities"("oidc_subject");
-- CreateIndex
CREATE UNIQUE INDEX "federation_messages_message_id_key" ON "federation_messages"("message_id");
-- CreateIndex
CREATE INDEX "federation_messages_workspace_id_idx" ON "federation_messages"("workspace_id");
-- CreateIndex
CREATE INDEX "federation_messages_connection_id_idx" ON "federation_messages"("connection_id");
-- CreateIndex
CREATE INDEX "federation_messages_message_id_idx" ON "federation_messages"("message_id");
-- CreateIndex
CREATE INDEX "federation_messages_correlation_id_idx" ON "federation_messages"("correlation_id");
-- CreateIndex
CREATE INDEX "federation_messages_event_type_idx" ON "federation_messages"("event_type");
-- AddForeignKey
ALTER TABLE "federation_connections" ADD CONSTRAINT "federation_connections_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federated_identities" ADD CONSTRAINT "federated_identities_local_user_id_fkey" FOREIGN KEY ("local_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federation_messages" ADD CONSTRAINT "federation_messages_connection_id_fkey" FOREIGN KEY ("connection_id") REFERENCES "federation_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federation_messages" ADD CONSTRAINT "federation_messages_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,7 +0,0 @@
-- Add version field for optimistic locking to prevent race conditions
-- This allows safe concurrent updates to runner job status
ALTER TABLE "runner_jobs" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
-- Create index for better performance on version checks
CREATE INDEX "runner_jobs_version_idx" ON "runner_jobs"("version");
@@ -1,34 +0,0 @@
-- CreateTable
CREATE TABLE "federation_event_subscriptions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"connection_id" UUID NOT NULL,
"event_type" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "federation_event_subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_workspace_id_idx" ON "federation_event_subscriptions"("workspace_id");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_connection_id_idx" ON "federation_event_subscriptions"("connection_id");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_event_type_idx" ON "federation_event_subscriptions"("event_type");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_workspace_id_is_active_idx" ON "federation_event_subscriptions"("workspace_id", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "federation_event_subscriptions_workspace_id_connection_id_even_key" ON "federation_event_subscriptions"("workspace_id", "connection_id", "event_type");
-- AddForeignKey
ALTER TABLE "federation_event_subscriptions" ADD CONSTRAINT "federation_event_subscriptions_connection_id_fkey" FOREIGN KEY ("connection_id") REFERENCES "federation_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federation_event_subscriptions" ADD CONSTRAINT "federation_event_subscriptions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,18 +0,0 @@
-- Rollback: SQL Injection Hardening for is_workspace_admin() Helper Function
-- This reverts the function to its previous implementation
-- =============================================================================
-- REVERT is_workspace_admin() to original implementation
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
AND role IN ('OWNER', 'ADMIN')
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
@@ -1,58 +0,0 @@
-- Security Fix: SQL Injection Hardening for is_workspace_admin() Helper Function
-- This migration adds explicit UUID validation to prevent SQL injection attacks
--
-- Related: #355 Code Review - Security CRIT-3
-- Original issue: Migration 20260129221004_add_rls_policies
-- =============================================================================
-- SECURITY FIX: Add explicit UUID validation to is_workspace_admin()
-- =============================================================================
-- The is_workspace_admin() function previously accepted UUID parameters without
-- explicit type casting/validation. Although PostgreSQL's parameter binding provides
-- some protection, explicit UUID type validation is a security best practice.
--
-- This fix adds explicit UUID validation using PostgreSQL's uuid type checking
-- to ensure that non-UUID values cannot bypass the function's intent.
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
DECLARE
-- Validate input parameters are valid UUIDs
v_workspace_id UUID;
v_user_id UUID;
BEGIN
-- Explicitly validate workspace_uuid parameter
IF workspace_uuid IS NULL THEN
RETURN FALSE;
END IF;
v_workspace_id := workspace_uuid::UUID;
-- Explicitly validate user_uuid parameter
IF user_uuid IS NULL THEN
RETURN FALSE;
END IF;
v_user_id := user_uuid::UUID;
-- Query with validated parameters
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = v_workspace_id
AND user_id = v_user_id
AND role IN ('OWNER', 'ADMIN')
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- NOTES
-- =============================================================================
-- This is a hardening fix that adds defense-in-depth to the is_workspace_admin()
-- helper function. While PostgreSQL's parameterized queries already provide
-- protection against SQL injection, explicit UUID type validation ensures:
--
-- 1. Parameters are explicitly cast to UUID type
-- 2. NULL values are handled defensively
-- 3. The function's intent is clear and secure
-- 4. Compliance with security best practices
--
-- This change is backward compatible and does not affect existing functionality.
@@ -1,91 +0,0 @@
-- Row-Level Security (RLS) for Auth Tables
-- This migration adds FORCE ROW LEVEL SECURITY and policies to accounts and sessions tables
-- to ensure users can only access their own authentication data.
--
-- Related: #350 - Add RLS policies to auth tables with FORCE enforcement
-- Design: docs/design/credential-security.md (Phase 1a)
-- =============================================================================
-- ENABLE FORCE RLS ON AUTH TABLES
-- =============================================================================
-- FORCE means the table owner (mosaic) is also subject to RLS policies.
-- This prevents Prisma (connecting as owner) from bypassing policies.
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounts FORCE ROW LEVEL SECURITY;
ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE sessions FORCE ROW LEVEL SECURITY;
-- =============================================================================
-- ACCOUNTS TABLE POLICIES
-- =============================================================================
-- Owner bypass policy: Allow access to all rows ONLY when no RLS context is set
-- This is required for:
-- 1. Prisma migrations that run without RLS context
-- 2. BetterAuth internal operations during authentication flow (when no user context)
-- 3. Database maintenance operations
-- When RLS context IS set (current_user_id() returns non-NULL), this policy does not apply
--
-- NOTE: If connecting as a PostgreSQL superuser (like the default 'mosaic' role),
-- RLS policies are bypassed entirely. For full RLS enforcement, the application
-- should connect as a non-superuser role. See docs/design/credential-security.md
CREATE POLICY accounts_owner_bypass ON accounts
FOR ALL
USING (current_user_id() IS NULL);
-- User access policy: Users can only access their own accounts
-- Uses current_user_id() helper from migration 20260129221004_add_rls_policies
-- This policy applies to all operations: SELECT, INSERT, UPDATE, DELETE
CREATE POLICY accounts_user_access ON accounts
FOR ALL
USING (user_id = current_user_id());
-- =============================================================================
-- SESSIONS TABLE POLICIES
-- =============================================================================
-- Owner bypass policy: Allow access to all rows ONLY when no RLS context is set
-- See note on accounts_owner_bypass policy about superuser limitations
CREATE POLICY sessions_owner_bypass ON sessions
FOR ALL
USING (current_user_id() IS NULL);
-- User access policy: Users can only access their own sessions
CREATE POLICY sessions_user_access ON sessions
FOR ALL
USING (user_id = current_user_id());
-- =============================================================================
-- VERIFICATION TABLE ANALYSIS
-- =============================================================================
-- The verifications table does NOT need RLS policies because:
-- 1. It stores ephemeral verification tokens (email verification, password reset)
-- 2. It has no user_id column - only identifier (email) and value (token)
-- 3. Tokens are short-lived and accessed by token value, not user context
-- 4. BetterAuth manages access control through token validation, not RLS
-- 5. No cross-user data leakage risk since tokens are random and expire
--
-- Therefore, we intentionally do NOT add RLS to verifications table.
-- =============================================================================
-- IMPORTANT: SUPERUSER LIMITATION
-- =============================================================================
-- PostgreSQL superusers (including the default 'mosaic' role) ALWAYS bypass
-- Row-Level Security policies, even with FORCE ROW LEVEL SECURITY enabled.
-- This is a fundamental PostgreSQL security design.
--
-- For production deployments with full RLS enforcement, create a dedicated
-- non-superuser application role:
--
-- CREATE ROLE mosaic_app WITH LOGIN PASSWORD 'secure-password';
-- GRANT CONNECT ON DATABASE mosaic TO mosaic_app;
-- GRANT USAGE ON SCHEMA public TO mosaic_app;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mosaic_app;
-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO mosaic_app;
--
-- Then update DATABASE_URL to connect as mosaic_app instead of mosaic.
-- The RLS policies will then be properly enforced for application queries.
--
-- See: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
@@ -1,76 +0,0 @@
-- Rollback: User Credentials Storage with RLS Policies
-- This migration reverses all changes from migration.sql
--
-- Related: #355 - Create UserCredential Prisma model with RLS policies
-- =============================================================================
-- DROP TRIGGERS AND FUNCTIONS
-- =============================================================================
DROP TRIGGER IF EXISTS user_credentials_updated_at ON user_credentials;
DROP FUNCTION IF EXISTS update_user_credentials_updated_at();
-- =============================================================================
-- DISABLE RLS
-- =============================================================================
ALTER TABLE user_credentials DISABLE ROW LEVEL SECURITY;
-- =============================================================================
-- DROP RLS POLICIES
-- =============================================================================
DROP POLICY IF EXISTS user_credentials_owner_bypass ON user_credentials;
DROP POLICY IF EXISTS user_credentials_user_access ON user_credentials;
DROP POLICY IF EXISTS user_credentials_workspace_access ON user_credentials;
-- =============================================================================
-- DROP INDEXES
-- =============================================================================
DROP INDEX IF EXISTS "user_credentials_user_id_workspace_id_provider_name_key";
DROP INDEX IF EXISTS "user_credentials_scope_is_active_idx";
DROP INDEX IF EXISTS "user_credentials_workspace_id_scope_idx";
DROP INDEX IF EXISTS "user_credentials_user_id_scope_idx";
DROP INDEX IF EXISTS "user_credentials_workspace_id_idx";
DROP INDEX IF EXISTS "user_credentials_user_id_idx";
-- =============================================================================
-- DROP FOREIGN KEY CONSTRAINTS
-- =============================================================================
ALTER TABLE "user_credentials" DROP CONSTRAINT IF EXISTS "user_credentials_workspace_id_fkey";
ALTER TABLE "user_credentials" DROP CONSTRAINT IF EXISTS "user_credentials_user_id_fkey";
-- =============================================================================
-- DROP TABLE
-- =============================================================================
DROP TABLE IF EXISTS "user_credentials";
-- =============================================================================
-- DROP ENUMS
-- =============================================================================
-- NOTE: ENUM values cannot be easily removed from an existing enum type in PostgreSQL.
-- To fully reverse this migration, you would need to:
--
-- 1. Remove the 'CREDENTIAL' value from EntityType enum (if not used elsewhere):
-- ALTER TYPE "EntityType" RENAME TO "EntityType_old";
-- CREATE TYPE "EntityType" AS ENUM (...all values except CREDENTIAL...);
-- -- Then rebuild all dependent objects
--
-- 2. Remove credential-related actions from ActivityAction enum (if not used elsewhere):
-- ALTER TYPE "ActivityAction" RENAME TO "ActivityAction_old";
-- CREATE TYPE "ActivityAction" AS ENUM (...all values except CREDENTIAL_*...);
-- -- Then rebuild all dependent objects
--
-- 3. Drop the CredentialType and CredentialScope enums:
-- DROP TYPE IF EXISTS "CredentialType";
-- DROP TYPE IF EXISTS "CredentialScope";
--
-- Due to the complexity and risk of breaking existing data/code that references
-- these enum values, this migration does NOT automatically remove them.
-- If you need to clean up the enums, manually execute the steps above.
--
-- For development environments, you can safely drop and recreate the enums manually
-- using the SQL statements above.
@@ -1,184 +0,0 @@
-- User Credentials Storage with RLS Policies
-- This migration adds the user_credentials table for secure storage of user API keys,
-- OAuth tokens, and other credentials with encryption and RLS enforcement.
--
-- Related: #355 - Create UserCredential Prisma model with RLS policies
-- Design: docs/design/credential-security.md (Phase 3a)
-- =============================================================================
-- CREATE ENUMS
-- =============================================================================
-- CredentialType enum: Types of credentials that can be stored
CREATE TYPE "CredentialType" AS ENUM ('API_KEY', 'OAUTH_TOKEN', 'ACCESS_TOKEN', 'SECRET', 'PASSWORD', 'CUSTOM');
-- CredentialScope enum: Access scope for credentials
CREATE TYPE "CredentialScope" AS ENUM ('USER', 'WORKSPACE', 'SYSTEM');
-- =============================================================================
-- EXTEND EXISTING ENUMS
-- =============================================================================
-- Add CREDENTIAL to EntityType for activity logging
ALTER TYPE "EntityType" ADD VALUE 'CREDENTIAL';
-- Add credential-related actions to ActivityAction
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_CREATED';
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_ACCESSED';
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_ROTATED';
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_REVOKED';
-- =============================================================================
-- CREATE USER_CREDENTIALS TABLE
-- =============================================================================
CREATE TABLE "user_credentials" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL,
"workspace_id" UUID,
-- Identity
"name" VARCHAR(255) NOT NULL,
"provider" VARCHAR(100) NOT NULL,
"type" "CredentialType" NOT NULL,
"scope" "CredentialScope" NOT NULL DEFAULT 'USER',
-- Encrypted storage
"encrypted_value" TEXT NOT NULL,
"masked_value" VARCHAR(20),
-- Metadata
"description" TEXT,
"expires_at" TIMESTAMPTZ,
"last_used_at" TIMESTAMPTZ,
"metadata" JSONB NOT NULL DEFAULT '{}',
-- Status
"is_active" BOOLEAN NOT NULL DEFAULT true,
"rotated_at" TIMESTAMPTZ,
-- Audit
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT "user_credentials_pkey" PRIMARY KEY ("id")
);
-- =============================================================================
-- CREATE FOREIGN KEY CONSTRAINTS
-- =============================================================================
ALTER TABLE "user_credentials" ADD CONSTRAINT "user_credentials_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_credentials" ADD CONSTRAINT "user_credentials_workspace_id_fkey"
FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- =============================================================================
-- CREATE INDEXES
-- =============================================================================
-- Index for user lookups
CREATE INDEX "user_credentials_user_id_idx" ON "user_credentials"("user_id");
-- Index for workspace lookups
CREATE INDEX "user_credentials_workspace_id_idx" ON "user_credentials"("workspace_id");
-- Index for user + scope queries
CREATE INDEX "user_credentials_user_id_scope_idx" ON "user_credentials"("user_id", "scope");
-- Index for workspace + scope queries
CREATE INDEX "user_credentials_workspace_id_scope_idx" ON "user_credentials"("workspace_id", "scope");
-- Index for scope + active status queries
CREATE INDEX "user_credentials_scope_is_active_idx" ON "user_credentials"("scope", "is_active");
-- =============================================================================
-- CREATE UNIQUE CONSTRAINT
-- =============================================================================
-- Prevent duplicate credentials per user/workspace/provider/name
CREATE UNIQUE INDEX "user_credentials_user_id_workspace_id_provider_name_key"
ON "user_credentials"("user_id", "workspace_id", "provider", "name");
-- =============================================================================
-- ENABLE FORCE ROW LEVEL SECURITY
-- =============================================================================
-- FORCE means the table owner (mosaic) is also subject to RLS policies.
-- This prevents Prisma (connecting as owner) from bypassing policies.
ALTER TABLE user_credentials ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_credentials FORCE ROW LEVEL SECURITY;
-- =============================================================================
-- RLS POLICIES
-- =============================================================================
-- Owner bypass policy: Allow access to all rows ONLY when no RLS context is set
-- This is required for:
-- 1. Prisma migrations that run without RLS context
-- 2. Database maintenance operations
-- When RLS context IS set (current_user_id() returns non-NULL), this policy does not apply
--
-- NOTE: If connecting as a PostgreSQL superuser (like the default 'mosaic' role),
-- RLS policies are bypassed entirely. For full RLS enforcement, the application
-- should connect as a non-superuser role. See docs/design/credential-security.md
CREATE POLICY user_credentials_owner_bypass ON user_credentials
FOR ALL
USING (current_user_id() IS NULL);
-- User access policy: USER-scoped credentials visible only to owner
-- Uses current_user_id() helper from migration 20260129221004_add_rls_policies
CREATE POLICY user_credentials_user_access ON user_credentials
FOR ALL
USING (
scope = 'USER' AND user_id = current_user_id()
);
-- Workspace admin access policy: WORKSPACE-scoped credentials visible to workspace admins
-- Uses is_workspace_admin() helper from migration 20260129221004_add_rls_policies
CREATE POLICY user_credentials_workspace_access ON user_credentials
FOR ALL
USING (
scope = 'WORKSPACE'
AND workspace_id IS NOT NULL
AND is_workspace_admin(workspace_id, current_user_id())
);
-- SYSTEM-scoped credentials are only accessible via owner bypass policy
-- (when current_user_id() IS NULL, which happens for admin operations)
-- =============================================================================
-- AUDIT TRIGGER
-- =============================================================================
-- Update updated_at timestamp on row changes
CREATE OR REPLACE FUNCTION update_user_credentials_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_credentials_updated_at
BEFORE UPDATE ON user_credentials
FOR EACH ROW
EXECUTE FUNCTION update_user_credentials_updated_at();
-- =============================================================================
-- NOTES
-- =============================================================================
-- This migration creates the foundation for secure credential storage.
-- The encrypted_value column stores ciphertext in one of two formats:
--
-- 1. OpenBao Transit format (preferred): vault:v1:base64data
-- 2. AES-256-GCM fallback format: iv:authTag:encrypted
--
-- The VaultService (issue #353) handles encryption/decryption with automatic
-- fallback to CryptoService when OpenBao is unavailable.
--
-- RLS enforcement ensures:
-- - USER scope: Only the credential owner can access
-- - WORKSPACE scope: Only workspace admins can access
-- - SYSTEM scope: Only accessible via admin/migration bypass
@@ -1,37 +0,0 @@
-- Encrypt existing plaintext Account tokens
-- This migration adds an encryption_version column and marks existing records for encryption
-- The actual encryption happens via Prisma middleware on first read/write
-- Add encryption_version column to track encryption state
-- NULL = not encrypted (legacy plaintext)
-- 'aes' = AES-256-GCM encrypted
-- 'vault' = OpenBao Transit encrypted (Phase 2)
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS encryption_version VARCHAR(20);
-- Create index for efficient queries filtering by encryption status
-- This index is also declared in Prisma schema (@@index([encryptionVersion]))
-- Using CREATE INDEX IF NOT EXISTS for idempotency
CREATE INDEX IF NOT EXISTS "accounts_encryption_version_idx" ON accounts(encryption_version);
-- Verify index was created successfully by running:
-- SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'accounts' AND indexname = 'accounts_encryption_version_idx';
-- Update statistics for query planner
ANALYZE accounts;
-- Migration Note:
-- This migration does NOT encrypt data in-place to avoid downtime and data corruption risks.
-- Instead, the Prisma middleware (account-encryption.middleware.ts) handles encryption:
--
-- 1. On READ: Detects format (plaintext vs encrypted) and decrypts if needed
-- 2. On WRITE: Encrypts tokens and sets encryption_version = 'aes'
-- 3. Backward compatible: Plaintext tokens (encryption_version = NULL) are passed through unchanged
--
-- To actively encrypt existing tokens, run the companion script:
-- node scripts/encrypt-account-tokens.js
--
-- This approach ensures:
-- - Zero downtime migration
-- - No risk of corrupting tokens during bulk encryption
-- - Progressive encryption as tokens are accessed/refreshed
-- - Easy rollback (middleware is idempotent)
@@ -1,26 +0,0 @@
-- Encrypt LLM Provider API Keys Migration
--
-- This migration enables transparent encryption/decryption of LLM provider API keys
-- stored in the llm_provider_instances.config JSON field.
--
-- IMPORTANT: This is a data migration with no schema changes.
--
-- Strategy:
-- 1. Prisma middleware (llm-encryption.middleware.ts) handles encryption/decryption
-- 2. Middleware auto-detects encryption format:
-- - vault:v1:... = OpenBao Transit encrypted
-- - Otherwise = Legacy plaintext (backward compatible)
-- 3. New API keys are always encrypted on write
-- 4. Existing plaintext keys work until re-saved (lazy migration)
--
-- To actively encrypt all existing API keys NOW:
-- pnpm --filter @mosaic/api migrate:encrypt-llm-keys
--
-- This approach ensures:
-- - Zero downtime migration
-- - No schema changes required
-- - Backward compatible with plaintext keys
-- - Progressive encryption as keys are accessed/updated
-- - Easy rollback (middleware is idempotent)
--
-- Note: No SQL changes needed. This file exists for migration tracking only.
@@ -1,197 +0,0 @@
-- RecreateEnum: FormalityLevel was dropped in 20260129235248_add_link_storage_fields
CREATE TYPE "FormalityLevel" AS ENUM ('VERY_CASUAL', 'CASUAL', 'NEUTRAL', 'FORMAL', 'VERY_FORMAL');
-- RecreateTable: personalities was dropped in 20260129235248_add_link_storage_fields
-- Recreated with current schema (display_name, system_prompt, temperature, etc.)
CREATE TABLE "personalities" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"description" TEXT,
"system_prompt" TEXT NOT NULL,
"temperature" DOUBLE PRECISION,
"max_tokens" INTEGER,
"llm_provider_instance_id" UUID,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "personalities_pkey" PRIMARY KEY ("id")
);
-- CreateIndex: personalities
CREATE UNIQUE INDEX "personalities_id_workspace_id_key" ON "personalities"("id", "workspace_id");
CREATE UNIQUE INDEX "personalities_workspace_id_name_key" ON "personalities"("workspace_id", "name");
CREATE INDEX "personalities_workspace_id_idx" ON "personalities"("workspace_id");
CREATE INDEX "personalities_workspace_id_is_default_idx" ON "personalities"("workspace_id", "is_default");
CREATE INDEX "personalities_workspace_id_is_enabled_idx" ON "personalities"("workspace_id", "is_enabled");
CREATE INDEX "personalities_llm_provider_instance_id_idx" ON "personalities"("llm_provider_instance_id");
-- AddForeignKey: personalities
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_llm_provider_instance_id_fkey" FOREIGN KEY ("llm_provider_instance_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "cron_schedules" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"expression" TEXT NOT NULL,
"command" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"last_run" TIMESTAMPTZ,
"next_run" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "cron_schedules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspace_llm_settings" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"default_llm_provider_id" UUID,
"default_personality_id" UUID,
"settings" JSONB DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "workspace_llm_settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quality_gates" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"command" TEXT,
"expected_output" TEXT,
"is_regex" BOOLEAN NOT NULL DEFAULT false,
"required" BOOLEAN NOT NULL DEFAULT true,
"order" INTEGER NOT NULL DEFAULT 0,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "quality_gates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_rejections" (
"id" UUID NOT NULL,
"task_id" TEXT NOT NULL,
"workspace_id" TEXT NOT NULL,
"agent_id" TEXT NOT NULL,
"attempt_count" INTEGER NOT NULL,
"failures" JSONB NOT NULL,
"original_task" TEXT NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"rejected_at" TIMESTAMPTZ NOT NULL,
"escalated" BOOLEAN NOT NULL DEFAULT false,
"manual_review" BOOLEAN NOT NULL DEFAULT false,
"resolved_at" TIMESTAMPTZ,
"resolution" TEXT,
CONSTRAINT "task_rejections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "token_budgets" (
"id" UUID NOT NULL,
"task_id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"allocated_tokens" INTEGER NOT NULL,
"estimated_complexity" TEXT NOT NULL,
"input_tokens_used" INTEGER NOT NULL DEFAULT 0,
"output_tokens_used" INTEGER NOT NULL DEFAULT 0,
"total_tokens_used" INTEGER NOT NULL DEFAULT 0,
"estimated_cost" DECIMAL(10,6),
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_updated_at" TIMESTAMPTZ NOT NULL,
"completed_at" TIMESTAMPTZ,
"budget_utilization" DOUBLE PRECISION,
"suspicious_pattern" BOOLEAN NOT NULL DEFAULT false,
"suspicious_reason" TEXT,
CONSTRAINT "token_budgets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "llm_usage_logs" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"provider" VARCHAR(50) NOT NULL,
"model" VARCHAR(100) NOT NULL,
"provider_instance_id" UUID,
"prompt_tokens" INTEGER NOT NULL DEFAULT 0,
"completion_tokens" INTEGER NOT NULL DEFAULT 0,
"total_tokens" INTEGER NOT NULL DEFAULT 0,
"cost_cents" DOUBLE PRECISION,
"task_type" VARCHAR(50),
"conversation_id" UUID,
"duration_ms" INTEGER,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "llm_usage_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex: cron_schedules
CREATE INDEX "cron_schedules_workspace_id_idx" ON "cron_schedules"("workspace_id");
CREATE INDEX "cron_schedules_workspace_id_enabled_idx" ON "cron_schedules"("workspace_id", "enabled");
CREATE INDEX "cron_schedules_next_run_idx" ON "cron_schedules"("next_run");
-- CreateIndex: workspace_llm_settings
CREATE UNIQUE INDEX "workspace_llm_settings_workspace_id_key" ON "workspace_llm_settings"("workspace_id");
CREATE INDEX "workspace_llm_settings_workspace_id_idx" ON "workspace_llm_settings"("workspace_id");
CREATE INDEX "workspace_llm_settings_default_llm_provider_id_idx" ON "workspace_llm_settings"("default_llm_provider_id");
CREATE INDEX "workspace_llm_settings_default_personality_id_idx" ON "workspace_llm_settings"("default_personality_id");
-- CreateIndex: quality_gates
CREATE UNIQUE INDEX "quality_gates_workspace_id_name_key" ON "quality_gates"("workspace_id", "name");
CREATE INDEX "quality_gates_workspace_id_idx" ON "quality_gates"("workspace_id");
CREATE INDEX "quality_gates_workspace_id_is_enabled_idx" ON "quality_gates"("workspace_id", "is_enabled");
-- CreateIndex: task_rejections
CREATE INDEX "task_rejections_task_id_idx" ON "task_rejections"("task_id");
CREATE INDEX "task_rejections_workspace_id_idx" ON "task_rejections"("workspace_id");
CREATE INDEX "task_rejections_agent_id_idx" ON "task_rejections"("agent_id");
CREATE INDEX "task_rejections_escalated_idx" ON "task_rejections"("escalated");
CREATE INDEX "task_rejections_manual_review_idx" ON "task_rejections"("manual_review");
-- CreateIndex: token_budgets
CREATE UNIQUE INDEX "token_budgets_task_id_key" ON "token_budgets"("task_id");
CREATE INDEX "token_budgets_task_id_idx" ON "token_budgets"("task_id");
CREATE INDEX "token_budgets_workspace_id_idx" ON "token_budgets"("workspace_id");
CREATE INDEX "token_budgets_suspicious_pattern_idx" ON "token_budgets"("suspicious_pattern");
-- CreateIndex: llm_usage_logs
CREATE INDEX "llm_usage_logs_workspace_id_idx" ON "llm_usage_logs"("workspace_id");
CREATE INDEX "llm_usage_logs_workspace_id_created_at_idx" ON "llm_usage_logs"("workspace_id", "created_at");
CREATE INDEX "llm_usage_logs_user_id_idx" ON "llm_usage_logs"("user_id");
CREATE INDEX "llm_usage_logs_provider_idx" ON "llm_usage_logs"("provider");
CREATE INDEX "llm_usage_logs_model_idx" ON "llm_usage_logs"("model");
CREATE INDEX "llm_usage_logs_provider_instance_id_idx" ON "llm_usage_logs"("provider_instance_id");
CREATE INDEX "llm_usage_logs_task_type_idx" ON "llm_usage_logs"("task_type");
CREATE INDEX "llm_usage_logs_conversation_id_idx" ON "llm_usage_logs"("conversation_id");
-- AddForeignKey: cron_schedules
ALTER TABLE "cron_schedules" ADD CONSTRAINT "cron_schedules_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: workspace_llm_settings
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_default_llm_provider_id_fkey" FOREIGN KEY ("default_llm_provider_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_default_personality_id_fkey" FOREIGN KEY ("default_personality_id") REFERENCES "personalities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: quality_gates
ALTER TABLE "quality_gates" ADD CONSTRAINT "quality_gates_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: llm_usage_logs
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_provider_instance_id_fkey" FOREIGN KEY ("provider_instance_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT;
@@ -1,49 +0,0 @@
-- Fix schema drift: tables, indexes, and constraints defined in schema.prisma
-- but never created (or dropped and never recreated) by prior migrations.
-- ============================================
-- CreateTable: instances (Federation module)
-- Never created in any prior migration
-- ============================================
CREATE TABLE "instances" (
"id" UUID NOT NULL,
"instance_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"public_key" TEXT NOT NULL,
"private_key" TEXT NOT NULL,
"capabilities" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "instances_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "instances_instance_id_key" ON "instances"("instance_id");
-- ============================================
-- Recreate dropped unique index on knowledge_links
-- Created in 20260129220645_add_knowledge_module, dropped in
-- 20260129235248_add_link_storage_fields, never recreated.
-- ============================================
CREATE UNIQUE INDEX "knowledge_links_source_id_target_id_key" ON "knowledge_links"("source_id", "target_id");
-- ============================================
-- Missing @@unique([id, workspaceId]) composite indexes
-- Defined in schema.prisma but never created in migrations.
-- (agent_tasks and runner_jobs already have these.)
-- ============================================
CREATE UNIQUE INDEX "tasks_id_workspace_id_key" ON "tasks"("id", "workspace_id");
CREATE UNIQUE INDEX "events_id_workspace_id_key" ON "events"("id", "workspace_id");
CREATE UNIQUE INDEX "projects_id_workspace_id_key" ON "projects"("id", "workspace_id");
CREATE UNIQUE INDEX "activity_logs_id_workspace_id_key" ON "activity_logs"("id", "workspace_id");
CREATE UNIQUE INDEX "domains_id_workspace_id_key" ON "domains"("id", "workspace_id");
CREATE UNIQUE INDEX "ideas_id_workspace_id_key" ON "ideas"("id", "workspace_id");
CREATE UNIQUE INDEX "user_layouts_id_workspace_id_key" ON "user_layouts"("id", "workspace_id");
-- ============================================
-- Missing index on agent_tasks.agent_type
-- Defined as @@index([agentType]) in schema.prisma
-- ============================================
CREATE INDEX "agent_tasks_agent_type_idx" ON "agent_tasks"("agent_type");
@@ -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;
@@ -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';
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
File diff suppressed because it is too large Load Diff
-599
View File
@@ -1,599 +0,0 @@
import {
PrismaClient,
TaskStatus,
TaskPriority,
ProjectStatus,
WorkspaceMemberRole,
EntryStatus,
Visibility,
} from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
console.log("Seeding database...");
// IMPORTANT: This seed script should not be run concurrently
// If running in CI/CD, ensure serialization of seed operations
// to prevent race conditions and data corruption
// Create test user
const user = await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: {
email: "[email protected]",
name: "Development User",
preferences: {
theme: "system",
notifications: true,
},
},
});
console.log(`Created user: ${user.email}`);
// Create workspace
const workspace = await prisma.workspace.upsert({
where: { id: "00000000-0000-0000-0000-000000000001" },
update: {},
create: {
id: "00000000-0000-0000-0000-000000000001",
name: "Development Workspace",
ownerId: user.id,
settings: {
timezone: "America/New_York",
},
},
});
console.log(`Created workspace: ${workspace.name}`);
// Add user as workspace owner
await prisma.workspaceMember.upsert({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
update: {},
create: {
workspaceId: workspace.id,
userId: user.id,
role: WorkspaceMemberRole.OWNER,
},
});
// ============================================
// 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
await prisma.$transaction(async (tx) => {
// Delete existing seed data for idempotency (avoids duplicates on re-run)
await tx.task.deleteMany({ where: { workspaceId: workspace.id } });
await tx.event.deleteMany({ where: { workspaceId: workspace.id } });
await tx.project.deleteMany({ where: { workspaceId: workspace.id } });
// Create sample project
const project = await tx.project.create({
data: {
workspaceId: workspace.id,
name: "Sample Project",
description: "A sample project for development",
status: ProjectStatus.ACTIVE,
creatorId: user.id,
color: "#3B82F6",
},
});
console.log(`Created project: ${project.name}`);
// Create sample tasks
const tasks = [
{
title: "Set up development environment",
status: TaskStatus.COMPLETED,
priority: TaskPriority.HIGH,
},
{
title: "Review project requirements",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.MEDIUM,
},
{
title: "Design database schema",
status: TaskStatus.COMPLETED,
priority: TaskPriority.HIGH,
},
{
title: "Implement NestJS integration",
status: TaskStatus.COMPLETED,
priority: TaskPriority.HIGH,
},
{
title: "Create seed data",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.MEDIUM,
},
];
// Use createMany for batch insertion (better performance)
await tx.task.createMany({
data: tasks.map((taskData) => ({
workspaceId: workspace.id,
title: taskData.title,
status: taskData.status,
priority: taskData.priority,
creatorId: user.id,
projectId: project.id,
})),
});
console.log(`Created ${tasks.length} sample tasks`);
// Create sample event
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
await tx.event.create({
data: {
workspaceId: workspace.id,
title: "Morning standup",
description: "Daily team sync",
startTime: tomorrow,
endTime: new Date(tomorrow.getTime() + 30 * 60000), // 30 minutes later
creatorId: user.id,
projectId: project.id,
},
});
console.log("Created sample event");
// ============================================
// KNOWLEDGE MODULE SEED DATA
// ============================================
// Delete existing knowledge data
await tx.knowledgeEmbedding.deleteMany({ where: { entry: { workspaceId: workspace.id } } });
await tx.knowledgeEntryTag.deleteMany({ where: { entry: { workspaceId: workspace.id } } });
await tx.knowledgeLink.deleteMany({ where: { source: { workspaceId: workspace.id } } });
await tx.knowledgeEntryVersion.deleteMany({ where: { entry: { workspaceId: workspace.id } } });
await tx.knowledgeEntry.deleteMany({ where: { workspaceId: workspace.id } });
await tx.knowledgeTag.deleteMany({ where: { workspaceId: workspace.id } });
// Create knowledge tags
const tags = await Promise.all([
tx.knowledgeTag.create({
data: {
workspaceId: workspace.id,
name: "Architecture",
slug: "architecture",
color: "#3B82F6",
description: "System architecture and design decisions",
},
}),
tx.knowledgeTag.create({
data: {
workspaceId: workspace.id,
name: "Development",
slug: "development",
color: "#10B981",
description: "Development practices and guidelines",
},
}),
tx.knowledgeTag.create({
data: {
workspaceId: workspace.id,
name: "Getting Started",
slug: "getting-started",
color: "#F59E0B",
description: "Onboarding and setup guides",
},
}),
]);
console.log(`Created ${tags.length} knowledge tags`);
// Create knowledge entries
const entries = [
{
slug: "welcome",
title: "Welcome to Mosaic Stack Knowledge Base",
content: `# Welcome to Mosaic Stack
This is the knowledge base for the Mosaic Stack project. Here you'll find:
- **[[architecture-overview]]** - High-level system architecture
- **[[development-setup]]** - Getting started with development
- **[[database-schema]]** - Database design and conventions
## About This Knowledge Base
The Knowledge Module provides:
- Wiki-style linking between entries
- Full-text and semantic search
- Version history and change tracking
- Tag-based organization
Start exploring by following the links above!`,
summary: "Introduction to the Mosaic Stack knowledge base and navigation guide",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["getting-started"],
},
{
slug: "architecture-overview",
title: "Architecture Overview",
content: `# Architecture Overview
The Mosaic Stack is built on a modern, scalable architecture:
## Stack Components
- **Frontend**: Next.js 15+ with React 19
- **Backend**: NestJS with Prisma ORM
- **Database**: PostgreSQL 17 with pgvector
- **Cache**: Valkey (Redis fork)
## Key Modules
1. **Task Management** - See [[development-setup]] for local setup
2. **Event Calendar** - Integrated scheduling
3. **Knowledge Base** - This module! See [[database-schema]]
4. **Agent Orchestration** - AI agent coordination
The database schema is documented in [[database-schema]].`,
summary: "High-level overview of Mosaic Stack architecture and components",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["architecture"],
},
{
slug: "development-setup",
title: "Development Setup Guide",
content: `# Development Setup Guide
## Prerequisites
- Node.js 22+
- PostgreSQL 17
- pnpm 9+
## Quick Start
\`\`\`bash
# Clone the repository
git clone https://git.mosaicstack.dev/mosaic/stack.git
# Install dependencies
pnpm install
# Set up database
cd apps/api
pnpm prisma migrate dev
pnpm prisma:seed
# Start development servers
pnpm dev
\`\`\`
## Architecture
Before diving in, review the [[architecture-overview]] to understand the system design.
## Database
The database schema is documented in [[database-schema]]. All models use Prisma ORM.
## Next Steps
1. Read the [[architecture-overview]]
2. Explore the codebase
3. Check out the [[database-schema]] documentation`,
summary: "Step-by-step guide to setting up the Mosaic Stack development environment",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["development", "getting-started"],
},
{
slug: "database-schema",
title: "Database Schema Documentation",
content: `# Database Schema Documentation
## Overview
Mosaic Stack uses PostgreSQL 17 with Prisma ORM. See [[architecture-overview]] for context.
## Key Conventions
- All IDs are UUIDs
- Timestamps use \`@db.Timestamptz\` (timezone-aware)
- Soft deletes via status fields (e.g., ARCHIVED)
- Relations enforce cascade deletes for data integrity
## Core Models
### Task Management
- \`Task\` - Individual work items
- \`Project\` - Task containers
- \`Domain\` - High-level categorization
### Knowledge Module
- \`KnowledgeEntry\` - Wiki pages
- \`KnowledgeLink\` - Page connections
- \`KnowledgeTag\` - Categorization
- \`KnowledgeEmbedding\` - Semantic search (pgvector)
### Agent System
- \`Agent\` - AI agent instances
- \`AgentSession\` - Conversation sessions
## Migrations
Migrations are managed via Prisma:
\`\`\`bash
# Create migration
pnpm prisma migrate dev --name my_migration
# Apply in production
pnpm prisma migrate deploy
\`\`\`
For setup instructions, see [[development-setup]].`,
summary:
"Comprehensive documentation of the Mosaic Stack database schema and Prisma conventions",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["architecture", "development"],
},
{
slug: "future-ideas",
title: "Future Ideas and Roadmap",
content: `# Future Ideas and Roadmap
## Planned Features
- Real-time collaboration (CRDT)
- Advanced graph visualizations
- AI-powered summarization
- Mobile app
## Research Areas
- Vector search optimization
- Knowledge graph algorithms
- Agent memory systems
This is a draft document. See [[architecture-overview]] for current state.`,
summary: "Brainstorming document for future features and research directions",
status: EntryStatus.DRAFT,
visibility: Visibility.PRIVATE,
tags: [],
},
];
// Create entries and track them for linking
const createdEntries = new Map<string, any>();
for (const entryData of entries) {
const entry = await tx.knowledgeEntry.create({
data: {
workspaceId: workspace.id,
slug: entryData.slug,
title: entryData.title,
content: entryData.content,
summary: entryData.summary,
status: entryData.status,
visibility: entryData.visibility,
createdBy: user.id,
updatedBy: user.id,
},
});
createdEntries.set(entryData.slug, entry);
// Create initial version
await tx.knowledgeEntryVersion.create({
data: {
entryId: entry.id,
version: 1,
title: entry.title,
content: entry.content,
summary: entry.summary,
createdBy: user.id,
changeNote: "Initial version",
},
});
// Add tags
for (const tagSlug of entryData.tags) {
const tag = tags.find((t) => t.slug === tagSlug);
if (tag) {
await tx.knowledgeEntryTag.create({
data: {
entryId: entry.id,
tagId: tag.id,
},
});
}
}
}
console.log(`Created ${entries.length} knowledge entries`);
// Create wiki-links between entries
const links = [
{ source: "welcome", target: "architecture-overview", text: "architecture-overview" },
{ source: "welcome", target: "development-setup", text: "development-setup" },
{ source: "welcome", target: "database-schema", text: "database-schema" },
{ source: "architecture-overview", target: "development-setup", text: "development-setup" },
{ source: "architecture-overview", target: "database-schema", text: "database-schema" },
{
source: "development-setup",
target: "architecture-overview",
text: "architecture-overview",
},
{ source: "development-setup", target: "database-schema", text: "database-schema" },
{ source: "database-schema", target: "architecture-overview", text: "architecture-overview" },
{ source: "database-schema", target: "development-setup", text: "development-setup" },
{ source: "future-ideas", target: "architecture-overview", text: "architecture-overview" },
];
for (const link of links) {
const sourceEntry = createdEntries.get(link.source);
const targetEntry = createdEntries.get(link.target);
if (sourceEntry && targetEntry) {
await tx.knowledgeLink.create({
data: {
sourceId: sourceEntry.id,
targetId: targetEntry.id,
linkText: link.text,
},
});
}
}
console.log(`Created ${links.length} knowledge links`);
});
console.log("Seeding completed successfully!");
}
main()
.catch((e) => {
console.error("Error seeding database:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-166
View File
@@ -1,166 +0,0 @@
/**
* Data Migration: Encrypt LLM Provider API Keys
*
* Encrypts all plaintext API keys in llm_provider_instances.config using OpenBao Transit.
* This script processes records in batches and runs in a transaction for safety.
*
* Usage:
* pnpm --filter @mosaic/api migrate:encrypt-llm-keys
*
* Environment Variables:
* DATABASE_URL - PostgreSQL connection string
* OPENBAO_ADDR - OpenBao server address (default: http://openbao:8200)
* APPROLE_CREDENTIALS_PATH - Path to AppRole credentials file
*/
import { PrismaClient } from "@prisma/client";
import { VaultService } from "../src/vault/vault.service";
import { TransitKey } from "../src/vault/vault.constants";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
interface LlmProviderConfig {
apiKey?: string;
[key: string]: unknown;
}
interface LlmProviderInstance {
id: string;
config: LlmProviderConfig;
providerType: string;
displayName: string;
}
/**
* Check if a value is already encrypted
*/
function isEncrypted(value: string): boolean {
if (!value || typeof value !== "string") {
return false;
}
// Vault format: vault:v1:...
if (value.startsWith("vault:v1:")) {
return true;
}
// AES format: iv:authTag:encrypted (3 colon-separated hex parts)
const parts = value.split(":");
if (parts.length === 3 && parts.every((part) => /^[0-9a-f]+$/i.test(part))) {
return true;
}
return false;
}
/**
* Main migration function
*/
async function main(): Promise<void> {
const logger = new Logger("EncryptLlmKeys");
const prisma = new PrismaClient();
try {
logger.log("Starting LLM API key encryption migration...");
// Initialize VaultService
const configService = new ConfigService();
const vaultService = new VaultService(configService);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await vaultService.onModuleInit();
logger.log("VaultService initialized successfully");
// Fetch all LLM provider instances
const instances = await prisma.llmProviderInstance.findMany({
select: {
id: true,
config: true,
providerType: true,
displayName: true,
},
});
logger.log(`Found ${String(instances.length)} LLM provider instances`);
let encryptedCount = 0;
let skippedCount = 0;
let errorCount = 0;
// Process each instance
for (const instance of instances as LlmProviderInstance[]) {
try {
const config = instance.config;
// Skip if no apiKey field
if (!config.apiKey || typeof config.apiKey !== "string") {
logger.debug(`Skipping ${instance.displayName} (${instance.id}): No API key`);
skippedCount++;
continue;
}
// Skip if already encrypted
if (isEncrypted(config.apiKey)) {
logger.debug(`Skipping ${instance.displayName} (${instance.id}): Already encrypted`);
skippedCount++;
continue;
}
// Encrypt the API key
logger.log(`Encrypting ${instance.displayName} (${instance.providerType})...`);
const encryptedApiKey = await vaultService.encrypt(config.apiKey, TransitKey.LLM_CONFIG);
// Update the instance with encrypted key
await prisma.llmProviderInstance.update({
where: { id: instance.id },
data: {
config: {
...config,
apiKey: encryptedApiKey,
},
},
});
encryptedCount++;
logger.log(`✓ Encrypted ${instance.displayName} (${instance.id})`);
} catch (error: unknown) {
errorCount++;
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`✗ Failed to encrypt ${instance.displayName} (${instance.id}): ${errorMsg}`);
}
}
// Summary
logger.log("\n=== Migration Summary ===");
logger.log(`Total instances: ${String(instances.length)}`);
logger.log(`Encrypted: ${String(encryptedCount)}`);
logger.log(`Skipped: ${String(skippedCount)}`);
logger.log(`Errors: ${String(errorCount)}`);
if (errorCount > 0) {
logger.warn("\n⚠️ Some API keys failed to encrypt. Please review the errors above.");
process.exit(1);
} else if (encryptedCount === 0) {
logger.log("\n✓ All API keys are already encrypted or no keys found.");
} else {
logger.log("\n✓ Migration completed successfully!");
}
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`Migration failed: ${errorMsg}`);
throw error;
} finally {
await prisma.$disconnect();
}
}
// Run migration
main()
.then(() => {
process.exit(0);
})
.catch((error: unknown) => {
console.error(error);
process.exit(1);
});
@@ -1,314 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { ActivityController } from "./activity.controller";
import { ActivityService } from "./activity.service";
import { ActivityAction, EntityType } from "@prisma/client";
import type { QueryActivityLogDto } from "./dto";
describe("ActivityController", () => {
let controller: ActivityController;
let service: ActivityService;
const mockActivityService = {
findAll: vi.fn(),
findOne: vi.fn(),
getAuditTrail: vi.fn(),
};
const mockWorkspaceId = "workspace-123";
beforeEach(() => {
service = mockActivityService as any;
controller = new ActivityController(service);
vi.clearAllMocks();
});
describe("findAll", () => {
const mockPaginatedResult = {
data: [
{
id: "activity-1",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: {},
createdAt: new Date("2024-01-01"),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
],
meta: {
total: 1,
page: 1,
limit: 50,
totalPages: 1,
},
};
it("should return paginated activity logs using authenticated user's workspaceId", async () => {
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
const result = await controller.findAll(query, mockWorkspaceId);
expect(result).toEqual(mockPaginatedResult);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should handle query with filters", async () => {
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
page: 1,
limit: 10,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockWorkspaceId);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should handle query with date range", async () => {
const startDate = new Date("2024-01-01");
const endDate = new Date("2024-01-31");
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
startDate,
endDate,
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockWorkspaceId);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should use user's workspaceId even if query provides different one", async () => {
const query: QueryActivityLogDto = {
workspaceId: "different-workspace",
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockWorkspaceId);
// Should use authenticated user's workspaceId, not query's
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
});
describe("findOne", () => {
const mockActivity = {
id: "activity-123",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
};
it("should return a single activity log using authenticated user's workspaceId", async () => {
mockActivityService.findOne.mockResolvedValue(mockActivity);
const result = await controller.findOne("activity-123", mockWorkspaceId);
expect(result).toEqual(mockActivity);
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", "workspace-123");
});
it("should return null if activity not found", async () => {
mockActivityService.findOne.mockResolvedValue(null);
const result = await controller.findOne("nonexistent", mockWorkspaceId);
expect(result).toBeNull();
});
it("should return null if workspaceId is missing (service handles gracefully)", async () => {
mockActivityService.findOne.mockResolvedValue(null);
const result = await controller.findOne("activity-123", undefined as any);
expect(result).toBeNull();
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", undefined);
});
});
describe("getAuditTrail", () => {
const mockAuditTrail = [
{
id: "activity-1",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: { title: "New Task" },
createdAt: new Date("2024-01-01"),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
{
id: "activity-2",
workspaceId: "workspace-123",
userId: "user-456",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: { title: "Updated Task" },
createdAt: new Date("2024-01-02"),
user: {
id: "user-456",
name: "Another User",
email: "[email protected]",
},
},
];
it("should return audit trail for a task using authenticated user's workspaceId", async () => {
mockActivityService.getAuditTrail.mockResolvedValue(mockAuditTrail);
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", mockWorkspaceId);
expect(result).toEqual(mockAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.TASK,
"task-123"
);
});
it("should return audit trail for an event", async () => {
const eventAuditTrail = [
{
id: "activity-3",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.EVENT,
entityId: "event-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
];
mockActivityService.getAuditTrail.mockResolvedValue(eventAuditTrail);
const result = await controller.getAuditTrail(EntityType.EVENT, "event-123", mockWorkspaceId);
expect(result).toEqual(eventAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.EVENT,
"event-123"
);
});
it("should return audit trail for a project", async () => {
const projectAuditTrail = [
{
id: "activity-4",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.PROJECT,
entityId: "project-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
];
mockActivityService.getAuditTrail.mockResolvedValue(projectAuditTrail);
const result = await controller.getAuditTrail(
EntityType.PROJECT,
"project-123",
mockWorkspaceId
);
expect(result).toEqual(projectAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.PROJECT,
"project-123"
);
});
it("should return empty array if no audit trail found", async () => {
mockActivityService.getAuditTrail.mockResolvedValue([]);
const result = await controller.getAuditTrail(
EntityType.WORKSPACE,
"workspace-999",
mockWorkspaceId
);
expect(result).toEqual([]);
});
it("should return empty array if workspaceId is missing (service handles gracefully)", async () => {
mockActivityService.getAuditTrail.mockResolvedValue([]);
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", undefined as any);
expect(result).toEqual([]);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
undefined,
EntityType.TASK,
"task-123"
);
});
});
});
@@ -1,35 +0,0 @@
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
import { ActivityService } from "./activity.service";
import { EntityType } from "@prisma/client";
import type { QueryActivityLogDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
@Controller("activity")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get()
@RequirePermission(Permission.WORKSPACE_ANY)
async findAll(@Query() query: QueryActivityLogDto, @Workspace() workspaceId: string) {
return this.activityService.findAll(Object.assign({}, query, { workspaceId }));
}
@Get("audit/:entityType/:entityId")
@RequirePermission(Permission.WORKSPACE_ANY)
async getAuditTrail(
@Param("entityType") entityType: EntityType,
@Param("entityId") entityId: string,
@Workspace() workspaceId: string
) {
return this.activityService.getAuditTrail(workspaceId, entityType, entityId);
}
@Get(":id")
@RequirePermission(Permission.WORKSPACE_ANY)
async findOne(@Param("id") id: string, @Workspace() workspaceId: string) {
return this.activityService.findOne(id, workspaceId);
}
}
-16
View File
@@ -1,16 +0,0 @@
import { Module } from "@nestjs/common";
import { ActivityController } from "./activity.controller";
import { ActivityService } from "./activity.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
/**
* Module for activity logging and audit trail functionality
*/
@Module({
imports: [PrismaModule, AuthModule],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}
File diff suppressed because it is too large Load Diff
-584
View File
@@ -1,584 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityAction, EntityType, Prisma, ActivityLog } from "@prisma/client";
import type {
CreateActivityLogInput,
PaginatedActivityLogs,
ActivityLogResult,
} from "./interfaces/activity.interface";
import type { QueryActivityLogDto } from "./dto";
/**
* Service for managing activity logs and audit trails
*/
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new activity log entry (fire-and-forget)
*
* Activity logging failures are logged but never propagate to callers.
* This ensures activity logging never breaks primary operations.
*
* @returns The created ActivityLog or null if logging failed
*/
async logActivity(input: CreateActivityLogInput): Promise<ActivityLog | null> {
try {
return await this.prisma.activityLog.create({
data: input as unknown as Prisma.ActivityLogCreateInput,
});
} catch (error) {
// Log the error but don't propagate - activity logging is fire-and-forget
this.logger.error(
`Failed to log activity: action=${input.action} entityType=${input.entityType} entityId=${input.entityId}`,
error instanceof Error ? error.stack : String(error)
);
return null;
}
}
/**
* Get paginated activity logs with filters
*/
async findAll(query: QueryActivityLogDto): Promise<PaginatedActivityLogs> {
const page = query.page ?? 1;
const limit = query.limit ?? 50;
const skip = (page - 1) * limit;
// Build where clause
const where: Prisma.ActivityLogWhereInput = {};
if (query.workspaceId !== undefined) {
where.workspaceId = query.workspaceId;
}
if (query.userId) {
where.userId = query.userId;
}
if (query.action) {
where.action = query.action;
}
if (query.entityType) {
where.entityType = query.entityType;
}
if (query.entityId) {
where.entityId = query.entityId;
}
if (query.startDate ?? query.endDate) {
where.createdAt = {};
if (query.startDate) {
where.createdAt.gte = query.startDate;
}
if (query.endDate) {
where.createdAt.lte = query.endDate;
}
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.activityLog.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.activityLog.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get a single activity log by ID
*/
async findOne(id: string, workspaceId: string): Promise<ActivityLogResult | null> {
return await this.prisma.activityLog.findUnique({
where: {
id,
workspaceId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
/**
* Get audit trail for a specific entity
*/
async getAuditTrail(
workspaceId: string,
entityType: EntityType,
entityId: string
): Promise<ActivityLogResult[]> {
return await this.prisma.activityLog.findMany({
where: {
workspaceId,
entityType,
entityId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "asc",
},
});
}
// ============================================
// HELPER METHODS FOR COMMON ACTIVITY TYPES
// ============================================
/**
* Log task creation
*/
async logTaskCreated(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task update
*/
async logTaskUpdated(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task deletion
*/
async logTaskDeleted(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task completion
*/
async logTaskCompleted(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.COMPLETED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task assignment
*/
async logTaskAssigned(
workspaceId: string,
userId: string,
taskId: string,
assigneeId: string
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.ASSIGNED,
entityType: EntityType.TASK,
entityId: taskId,
details: { assigneeId },
});
}
/**
* Log event creation
*/
async logEventCreated(
workspaceId: string,
userId: string,
eventId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log event update
*/
async logEventUpdated(
workspaceId: string,
userId: string,
eventId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log event deletion
*/
async logEventDeleted(
workspaceId: string,
userId: string,
eventId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log project creation
*/
async logProjectCreated(
workspaceId: string,
userId: string,
projectId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log project update
*/
async logProjectUpdated(
workspaceId: string,
userId: string,
projectId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log project deletion
*/
async logProjectDeleted(
workspaceId: string,
userId: string,
projectId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log workspace creation
*/
async logWorkspaceCreated(
workspaceId: string,
userId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
...(details && { details }),
});
}
/**
* Log workspace update
*/
async logWorkspaceUpdated(
workspaceId: string,
userId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
...(details && { details }),
});
}
/**
* Log workspace member added
*/
async logWorkspaceMemberAdded(
workspaceId: string,
userId: string,
memberId: string,
role: string
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
details: { memberId, role },
});
}
/**
* Log workspace member removed
*/
async logWorkspaceMemberRemoved(
workspaceId: string,
userId: string,
memberId: string
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
details: { memberId },
});
}
/**
* Log user profile update
*/
async logUserUpdated(
workspaceId: string,
userId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.USER,
entityId: userId,
...(details && { details }),
});
}
/**
* Log domain creation
*/
async logDomainCreated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain update
*/
async logDomainUpdated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain deletion
*/
async logDomainDeleted(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log idea creation
*/
async logIdeaCreated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea update
*/
async logIdeaUpdated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea deletion
*/
async logIdeaDeleted(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
}
@@ -1,348 +0,0 @@
import { describe, it, expect } from "vitest";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { CreateActivityLogDto } from "./create-activity-log.dto";
import { ActivityAction, EntityType } from "@prisma/client";
describe("CreateActivityLogDto", () => {
describe("required fields validation", () => {
it("should pass with all required fields valid", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when workspaceId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
expect(workspaceIdError).toBeDefined();
});
it("should fail when userId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userIdError = errors.find((e) => e.property === "userId");
expect(userIdError).toBeDefined();
});
it("should fail when action is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const actionError = errors.find((e) => e.property === "action");
expect(actionError).toBeDefined();
});
it("should fail when entityType is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityTypeError = errors.find((e) => e.property === "entityType");
expect(entityTypeError).toBeDefined();
});
it("should fail when entityId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityIdError = errors.find((e) => e.property === "entityId");
expect(entityIdError).toBeDefined();
});
});
describe("UUID validation", () => {
it("should fail with invalid workspaceId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "invalid-uuid",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("workspaceId");
});
it("should fail with invalid userId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "not-a-uuid",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userIdError = errors.find((e) => e.property === "userId");
expect(userIdError).toBeDefined();
});
it("should fail with invalid entityId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "bad-entity-id",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityIdError = errors.find((e) => e.property === "entityId");
expect(entityIdError).toBeDefined();
});
});
describe("enum validation", () => {
it("should pass with all valid ActivityAction values", async () => {
const actions = Object.values(ActivityAction);
for (const action of actions) {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
});
it("should fail with invalid action value", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: "INVALID_ACTION",
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const actionError = errors.find((e) => e.property === "action");
expect(actionError?.constraints?.isEnum).toBeDefined();
});
it("should pass with all valid EntityType values", async () => {
const entityTypes = Object.values(EntityType);
for (const entityType of entityTypes) {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
});
it("should fail with invalid entityType value", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: "INVALID_TYPE",
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityTypeError = errors.find((e) => e.property === "entityType");
expect(entityTypeError?.constraints?.isEnum).toBeDefined();
});
});
describe("optional fields validation", () => {
it("should pass with valid details object", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: {
field: "status",
oldValue: "TODO",
newValue: "IN_PROGRESS",
},
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with non-object details", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: "not an object",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const detailsError = errors.find((e) => e.property === "details");
expect(detailsError?.constraints?.isObject).toBeDefined();
});
it("should pass with valid ipAddress", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "192.168.1.1",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should pass with valid IPv6 address", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when ipAddress exceeds max length", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "a".repeat(46),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const ipError = errors.find((e) => e.property === "ipAddress");
expect(ipError?.constraints?.maxLength).toBeDefined();
});
it("should pass with valid userAgent", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when userAgent exceeds max length", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
userAgent: "a".repeat(501),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userAgentError = errors.find((e) => e.property === "userAgent");
expect(userAgentError?.constraints?.maxLength).toBeDefined();
});
it("should pass when optional fields are not provided", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("complete validation", () => {
it("should pass with all fields valid", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: {
changes: ["status", "priority"],
metadata: { source: "web-app" },
},
ipAddress: "10.0.0.1",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
});
@@ -1,36 +0,0 @@
import { ActivityAction, EntityType } from "@prisma/client";
import { IsUUID, IsEnum, IsOptional, IsObject, IsString, MaxLength } from "class-validator";
/**
* DTO for creating a new activity log entry
*/
export class CreateActivityLogDto {
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId!: string;
@IsUUID("4", { message: "userId must be a valid UUID" })
userId!: string;
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
action!: ActivityAction;
@IsEnum(EntityType, { message: "entityType must be a valid EntityType" })
entityType!: EntityType;
@IsUUID("4", { message: "entityId must be a valid UUID" })
entityId!: string;
@IsOptional()
@IsObject({ message: "details must be an object" })
details?: Record<string, unknown>;
@IsOptional()
@IsString({ message: "ipAddress must be a string" })
@MaxLength(45, { message: "ipAddress must not exceed 45 characters" })
ipAddress?: string;
@IsOptional()
@IsString({ message: "userAgent must be a string" })
@MaxLength(500, { message: "userAgent must not exceed 500 characters" })
userAgent?: string;
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./create-activity-log.dto";
export * from "./query-activity-log.dto";
@@ -1,254 +0,0 @@
import { describe, it, expect } from "vitest";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { QueryActivityLogDto } from "./query-activity-log.dto";
import { ActivityAction, EntityType } from "@prisma/client";
describe("QueryActivityLogDto", () => {
describe("workspaceId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "invalid-uuid",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("workspaceId");
expect(errors[0].constraints?.isUuid).toBeDefined();
});
it("should pass when workspaceId is missing (it's optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {});
const errors = await validate(dto);
// workspaceId is optional in DTO since it's set by controller from @Workspace() decorator
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
expect(workspaceIdError).toBeUndefined();
});
});
describe("userId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "not-a-uuid",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("userId");
});
it("should pass when userId is not provided (optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("action validation", () => {
it("should pass with valid ActivityAction", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: ActivityAction.CREATED,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid action value", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: "INVALID_ACTION",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("action");
});
it("should pass when action is not provided (optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("entityType validation", () => {
it("should pass with valid EntityType", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityType: EntityType.TASK,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid entityType value", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityType: "INVALID_TYPE",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("entityType");
});
});
describe("entityId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityId: "invalid-entity-id",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("entityId");
});
});
describe("date validation", () => {
it("should pass with valid ISO date strings", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid date format", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
startDate: "not-a-date",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("startDate");
});
});
describe("pagination validation", () => {
it("should pass with valid page and limit", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "1",
limit: "50",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.page).toBe(1);
expect(dto.limit).toBe(50);
});
it("should fail when page is less than 1", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "0",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError?.constraints?.min).toBeDefined();
});
it("should fail when limit exceeds 100", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
limit: "101",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError?.constraints?.max).toBeDefined();
});
it("should fail when page is not an integer", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "1.5",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError?.constraints?.isInt).toBeDefined();
});
it("should fail when limit is not an integer", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
limit: "50.5",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError?.constraints?.isInt).toBeDefined();
});
});
describe("multiple filters", () => {
it("should pass with all valid filters combined", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: "550e8400-e29b-41d4-a716-446655440002",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
page: "2",
limit: "25",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
});
@@ -1,49 +0,0 @@
import { ActivityAction, EntityType } from "@prisma/client";
import { IsUUID, IsEnum, IsOptional, IsInt, Min, Max, IsDateString } from "class-validator";
import { Type } from "class-transformer";
/**
* DTO for querying activity logs with filters and pagination
*/
export class QueryActivityLogDto {
@IsOptional()
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId?: string;
@IsOptional()
@IsUUID("4", { message: "userId must be a valid UUID" })
userId?: string;
@IsOptional()
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
action?: ActivityAction;
@IsOptional()
@IsEnum(EntityType, { message: "entityType must be a valid EntityType" })
entityType?: EntityType;
@IsOptional()
@IsUUID("4", { message: "entityId must be a valid UUID" })
entityId?: string;
@IsOptional()
@IsDateString({}, { message: "startDate must be a valid ISO 8601 date string" })
startDate?: Date;
@IsOptional()
@IsDateString({}, { message: "endDate must be a valid ISO 8601 date string" })
endDate?: Date;
@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;
}
@@ -1,766 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ActivityLoggingInterceptor } from "./activity-logging.interceptor";
import { ActivityService } from "../activity.service";
import { ExecutionContext, CallHandler } from "@nestjs/common";
import { of } from "rxjs";
import { ActivityAction, EntityType } from "@prisma/client";
describe("ActivityLoggingInterceptor", () => {
let interceptor: ActivityLoggingInterceptor;
let activityService: ActivityService;
const mockActivityService = {
logActivity: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ActivityLoggingInterceptor,
{
provide: ActivityService,
useValue: mockActivityService,
},
],
}).compile();
interceptor = module.get<ActivityLoggingInterceptor>(ActivityLoggingInterceptor);
activityService = module.get<ActivityService>(ActivityService);
vi.clearAllMocks();
});
const createMockExecutionContext = (
method: string,
params: any = {},
body: any = {},
user: any = null,
ip = "127.0.0.1",
userAgent = "test-agent"
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => ({
method,
params,
body,
user,
ip,
headers: {
"user-agent": userAgent,
},
}),
}),
getClass: () => ({ name: "TestController" }),
getHandler: () => ({ name: "testMethod" }),
} as any;
};
const createMockCallHandler = (result: any = {}): CallHandler => {
return {
handle: () => of(result),
} as any;
};
describe("intercept", () => {
it("should not log if user is not authenticated", async () => {
const context = createMockExecutionContext("POST", {}, {}, null);
const next = createMockCallHandler();
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should log POST request as CREATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
workspaceId: "workspace-123",
title: "New Task",
};
const context = createMockExecutionContext(
"POST",
{},
body,
user,
"127.0.0.1",
"Mozilla/5.0"
);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: expect.any(String),
entityId: "task-123",
details: expect.objectContaining({
method: "POST",
controller: "TestController",
handler: "testMethod",
}),
ipAddress: "127.0.0.1",
userAgent: "Mozilla/5.0",
});
resolve();
});
});
});
it("should log PATCH request as UPDATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "task-456",
};
const body = {
status: "IN_PROGRESS",
};
const result = {
id: "task-456",
workspaceId: "workspace-123",
status: "IN_PROGRESS",
};
const context = createMockExecutionContext("PATCH", params, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-124",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.UPDATED,
entityType: expect.any(String),
entityId: "task-456",
details: expect.objectContaining({
method: "PATCH",
changes: body,
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should log PUT request as UPDATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "event-789",
};
const body = {
title: "Updated Event",
};
const result = {
id: "event-789",
workspaceId: "workspace-123",
title: "Updated Event",
};
const context = createMockExecutionContext("PUT", params, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-125",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.UPDATED,
entityType: expect.any(String),
entityId: "event-789",
details: expect.objectContaining({
method: "PUT",
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should log DELETE request as DELETE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "project-999",
};
const result = {
id: "project-999",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("DELETE", params, {}, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-126",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.DELETED,
entityType: expect.any(String),
entityId: "project-999",
details: expect.objectContaining({
method: "DELETE",
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should not log GET requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("GET", {}, {}, user);
const next = createMockCallHandler({ data: [] });
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should extract entity ID from result if not in params", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
id: "task-new-123",
workspaceId: "workspace-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-127",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
entityId: "task-new-123",
})
);
resolve();
});
});
});
it("should handle errors gracefully", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, {}, user);
const next = createMockCallHandler({ id: "test-123" });
mockActivityService.logActivity.mockRejectedValue(new Error("Logging failed"));
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not throw error, just log it
resolve();
});
});
});
});
describe("edge cases", () => {
it("should handle POST request with no id field in response", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
workspaceId: "workspace-123",
title: "New Task",
// No 'id' field in response
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when entityId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle user object missing workspaceId", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle body missing workspaceId when user also missing workspaceId", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
// No workspaceId
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should extract workspaceId from body when not in user object", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
workspaceId: "workspace-from-body",
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "workspace-from-body",
})
);
resolve();
});
});
});
it("should handle null result from handler", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("DELETE", { id: "task-123" }, {}, user);
const next = createMockCallHandler(null);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should still log activity with entityId from params
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
entityId: "task-123",
workspaceId: "workspace-123",
})
);
resolve();
});
});
});
it("should handle undefined result from handler", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, { title: "New Task" }, user);
const next = createMockCallHandler(undefined);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log when entityId cannot be determined
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should log warning when entityId is missing", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
workspaceId: "workspace-123",
title: "New Task",
// No 'id' field
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
resolve();
});
});
consoleSpy.mockRestore();
});
it("should log warning when workspaceId is missing", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
resolve();
});
});
consoleSpy.mockRestore();
});
it("should handle activity service throwing an error", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, {}, user);
const next = createMockCallHandler({ id: "test-123" });
const activityError = new Error("Activity logging failed");
mockActivityService.logActivity.mockRejectedValue(activityError);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not throw error, just log it
resolve();
});
});
});
it("should handle OPTIONS requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("OPTIONS", {}, {}, user);
const next = createMockCallHandler({});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log OPTIONS requests
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle HEAD requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("HEAD", {}, {}, user);
const next = createMockCallHandler({});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log HEAD requests
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
});
describe("sensitive data sanitization", () => {
it("should redact password field", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
username: "testuser",
password: "secret123",
email: "[email protected]",
};
const result = {
id: "user-456",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.password).toBe("[REDACTED]");
expect(logCall.details.data.username).toBe("testuser");
expect(logCall.details.data.email).toBe("[email protected]");
resolve();
});
});
});
it("should redact token field", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Integration",
apiToken: "sk_test_1234567890",
};
const result = {
id: "integration-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-124",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.apiToken).toBe("[REDACTED]");
expect(logCall.details.data.title).toBe("Integration");
resolve();
});
});
});
it("should redact sensitive fields in nested objects", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Config",
settings: {
apiKey: "secret_key",
public: "visible_data",
auth: {
token: "auth_token_123",
refreshToken: "refresh_token_456",
},
},
};
const result = {
id: "config-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-128",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.title).toBe("Config");
expect(logCall.details.data.settings.apiKey).toBe("[REDACTED]");
expect(logCall.details.data.settings.public).toBe("visible_data");
expect(logCall.details.data.settings.auth.token).toBe("[REDACTED]");
expect(logCall.details.data.settings.auth.refreshToken).toBe("[REDACTED]");
resolve();
});
});
});
it("should not modify non-sensitive fields", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Safe Data",
description: "This is public",
count: 42,
active: true,
};
const result = {
id: "item-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-130",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data).toEqual(body);
resolve();
});
});
});
});
});

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