diff --git a/.ci/pipeline.yml b/.ci/pipeline.yml new file mode 100644 index 000000000..62c169be7 --- /dev/null +++ b/.ci/pipeline.yml @@ -0,0 +1,171 @@ +# Generic CI/CD Pipeline Configuration +# This configuration can be adapted for various CI/CD platforms + +variables: + POSTGRES_DB: powernode_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST: postgres + RAILS_ENV: test + NODE_ENV: test + +stages: + - setup + - test + - build + - security + - deploy + +# Setup stage +setup:backend: + stage: setup + script: + - cd server + - bundle install + - bundle exec rails db:create db:migrate + - bundle exec rails db:seed + cache: + paths: + - server/vendor/bundle + - server/.bundle + +setup:frontend: + stage: setup + script: + - cd frontend + - npm ci + - npm run build + cache: + paths: + - frontend/node_modules + - frontend/.npm + +# Test stage +test:backend: + stage: test + dependencies: + - setup:backend + script: + - cd server + - bundle exec rspec --format progress --format RspecJunitFormatter --out rspec.xml + - bundle exec rails test:security + artifacts: + reports: + junit: server/rspec.xml + paths: + - server/coverage/ + expire_in: 30 days + coverage: '/\(\d+.\d+\%\) covered/' + +test:frontend: + stage: test + dependencies: + - setup:frontend + script: + - cd frontend + - npm run test -- --coverage --watchAll=false --testResultsProcessor=jest-junit + - npm run lint + - npm run lint:security + artifacts: + reports: + junit: frontend/junit.xml + paths: + - frontend/coverage/ + expire_in: 30 days + coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/' + +test:e2e: + stage: test + dependencies: + - setup:backend + - setup:frontend + script: + - cd server && bundle exec rails server -e test -p 3000 -d + - cd frontend && npm start & + - sleep 30 + - cd frontend && npm run cypress:run + artifacts: + when: always + paths: + - frontend/cypress/videos/ + - frontend/cypress/screenshots/ + expire_in: 30 days + +# Build stage +build:backend: + stage: build + script: + - cd server + - bundle install --deployment --without development test + - bundle exec rails assets:precompile + artifacts: + paths: + - server/public/assets/ + expire_in: 1 week + +build:frontend: + stage: build + script: + - cd frontend + - npm ci --only=production + - npm run build + artifacts: + paths: + - frontend/build/ + expire_in: 1 week + +# Security stage +security:backend: + stage: security + script: + - cd server + - bundle exec bundle-audit check --update + - bundle exec brakeman -q -o brakeman.json + artifacts: + reports: + sast: server/brakeman.json + allow_failure: true + +security:frontend: + stage: security + script: + - cd frontend + - npm audit --audit-level moderate + - npm run lint:security + allow_failure: true + +security:secrets: + stage: security + script: + - | + echo "Scanning for secrets..." + find . -name "*.rb" -o -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.yml" -o -name "*.yaml" | \ + xargs grep -l -E "(password|secret|key|token|api_key)" | \ + head -10 + allow_failure: true + +# Deploy stage +deploy:staging: + stage: deploy + environment: + name: staging + script: + - echo "Deploying to staging environment..." + - echo "Database migrations would run here" + - echo "Application deployment would happen here" + only: + - develop + when: manual + +deploy:production: + stage: deploy + environment: + name: production + script: + - echo "Deploying to production environment..." + - echo "Database migrations would run here" + - echo "Application deployment would happen here" + only: + - main + - master + when: manual \ No newline at end of file diff --git a/.claude/hooks/console-log-check.sh b/.claude/hooks/console-log-check.sh new file mode 100755 index 000000000..31627f19b --- /dev/null +++ b/.claude/hooks/console-log-check.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Advisory hook: warns when console.log/debug/info is introduced in frontend TS/TSX files +# Suggests using the centralized logger instead + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty') + +[[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]] && exit 0 +[[ "$FILE_PATH" != *frontend/src* ]] && exit 0 +[[ ! -f "$FILE_PATH" ]] && exit 0 + +BASENAME=$(basename "$FILE_PATH") +[[ "$BASENAME" == "logger.ts" ]] && exit 0 +[[ "$BASENAME" == *".test."* || "$BASENAME" == *".spec."* ]] && exit 0 +[[ "$BASENAME" == "CodeSamples.tsx" ]] && exit 0 + +MATCHES=$(grep -n 'console\.\(log\|debug\|info\)(' "$FILE_PATH" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*') +if [[ -n "$MATCHES" ]]; then + echo "Advisory: console.log/debug/info found in $FILE_PATH" >&2 + echo "$MATCHES" >&2 + echo "Use: import { logger } from '@/shared/utils/logger'" >&2 +fi +exit 0 diff --git a/.claude/hooks/controller-size-check.sh b/.claude/hooks/controller-size-check.sh new file mode 100755 index 000000000..8e97cc21e --- /dev/null +++ b/.claude/hooks/controller-size-check.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Advisory hook: warns when a controller file exceeds 300 lines +# Suggests extraction to concerns, service objects, or serializers + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty') + +[[ "$FILE_PATH" != *_controller.rb ]] && exit 0 +[[ ! -f "$FILE_PATH" ]] && exit 0 + +LINE_COUNT=$(wc -l < "$FILE_PATH") +if [[ "$LINE_COUNT" -gt 300 ]]; then + echo "Advisory: $FILE_PATH is $LINE_COUNT lines (target: <300)" >&2 + echo "Consider extracting to concerns, service objects, or serializers" >&2 + if [[ "$LINE_COUNT" -gt 500 ]]; then + echo "WARNING: File exceeds 500-line modular design limit" >&2 + fi +fi +exit 0 diff --git a/.claude/hooks/mcp-helper.sh b/.claude/hooks/mcp-helper.sh new file mode 100755 index 000000000..fc4c53569 --- /dev/null +++ b/.claude/hooks/mcp-helper.sh @@ -0,0 +1,433 @@ +#!/usr/bin/env bash +# MCP Helper — reusable functions for invoking Powernode MCP tools from Claude Code sessions. +# +# Usage: +# source .claude/hooks/mcp-helper.sh +# mcp_token # Get/cache OAuth token +# mcp_call "platform.tool_name" '{}' # Invoke any platform.* tool +# +# Dependencies: curl, python3 (for JSON formatting) +# Token/session files are shared with workspace-sse-daemon.sh + +set -eo pipefail + +# --- Configuration (shared with workspace-sse-daemon.sh) --- +MCP_PLATFORM_URL="${POWERNODE_URL:-http://localhost:3000}" +MCP_ENDPOINT="${MCP_PLATFORM_URL}/api/v1/mcp/message" +MCP_SERVER_DIR="${POWERNODE_ROOT:-/opt/powernode}/server" +MCP_TOKEN_FILE="/tmp/powernode_mcp_token.txt" # shared (unchanged) +MCP_INSTANCE_ID="${MCP_INSTANCE_ID:-${PPID}}" +MCP_SESSION_FILE="/tmp/powernode_mcp_session_${MCP_INSTANCE_ID}.txt" # per-instance +MCP_SESSION_NAME_FILE="/tmp/powernode_mcp_session_name_${MCP_INSTANCE_ID}.txt" # per-instance +MCP_CC_CREDENTIALS="${HOME}/.claude/.credentials.json" # Claude Code OAuth credentials + +# Detect remote mode: POWERNODE_URL is not localhost +_mcp_is_remote() { + [[ "$MCP_PLATFORM_URL" != *"localhost"* && "$MCP_PLATFORM_URL" != *"127.0.0.1"* ]] +} + +# --- Token Management --- + +# Extract OAuth access token from Claude Code's credentials file. +# Used in remote mode where rails runner tokens are invalid. +_mcp_cc_token() { + [[ -f "$MCP_CC_CREDENTIALS" ]] || return 1 + python3 -c " +import json, sys, time +with open(sys.argv[1]) as f: + d = json.load(f) +for key, val in d.get('mcpOAuth', {}).items(): + if key.startswith('powernode'): + expires = val.get('expiresAt', 0) + # expiresAt is in milliseconds + if expires > time.time() * 1000: + print(val['accessToken'], end='') + sys.exit(0) + else: + print('EXPIRED', file=sys.stderr) + sys.exit(1) +sys.exit(1) +" "$MCP_CC_CREDENTIALS" 2>/dev/null +} + +# Get or refresh the MCP OAuth token. Returns the token on stdout. +# In remote mode, reads from Claude Code's credentials file. +# In local mode, generates tokens via rails runner. +mcp_token() { + # Remote mode: always read from Claude Code's credentials file (cheap file read). + # Skips the age-based cache to avoid stale tokens after OAuth rotation. + if _mcp_is_remote; then + local cc_token + cc_token=$(_mcp_cc_token) || { + echo "ERROR: Cannot read Powernode OAuth token from Claude Code credentials. Reconnect via /mcp." >&2 + return 1 + } + echo -n "$cc_token" > "$MCP_TOKEN_FILE" + echo "$cc_token" + return 0 + fi + + # Local mode: return cached token if fresh (< 25 min old, avoids expensive rails runner) + if [[ -f "$MCP_TOKEN_FILE" && -s "$MCP_TOKEN_FILE" ]]; then + local age + age=$(( $(date +%s) - $(stat -c%Y "$MCP_TOKEN_FILE") )) + if (( age < 1500 )); then + cat "$MCP_TOKEN_FILE" + return 0 + fi + fi + + # Local mode: resolve identifiers and generate token via rails runner + local ids_cache="/tmp/powernode_sse_ids_cache.txt" + if [[ -f "$ids_cache" && -s "$ids_cache" ]]; then + source "$ids_cache" + else + echo "ERROR: No cached identifiers. Start the SSE daemon first: .claude/hooks/workspace-sse-daemon.sh start" >&2 + return 1 + fi + + local new_token + new_token=$(cd "$MCP_SERVER_DIR" && bin/rails runner " +app = Doorkeeper::Application.find('$OAUTH_APP_ID') +token = Doorkeeper::AccessToken.create!( + application: app, + resource_owner_id: '$RESOURCE_OWNER_ID', + scopes: 'read write', + expires_in: 7200, + use_refresh_token: false +) +print token.plaintext_token || token.token +" 2>/dev/null) + + if [[ -n "$new_token" && ${#new_token} -gt 10 ]]; then + echo -n "$new_token" > "$MCP_TOKEN_FILE" + echo "$new_token" + return 0 + else + echo "ERROR: Token refresh failed" >&2 + return 1 + fi +} + +# --- MCP Session --- + +# Picks an unclaimed session from the session/discover response. +# Checks /tmp/powernode_mcp_session_*.txt files to see which sessions are +# already claimed by other live Claude Code instances. Returns 0 and prints +# the session token (tab-separated with display_name) on success. +_mcp_pick_unclaimed_session() { + local discover_json="$1" + local my_instance="${MCP_INSTANCE_ID:-}" + + python3 -c " +import json, sys, os, glob + +data = json.loads(sys.argv[1]) +my_instance = sys.argv[2] if len(sys.argv) > 2 else '' + +sessions = data.get('result', {}).get('sessions', []) +if not sessions: + sys.exit(1) + +# Build set of session tokens already claimed by live processes +claimed = {} +for f in glob.glob('/tmp/powernode_mcp_session_*.txt'): + base = os.path.basename(f) + if 'name' in base: + continue + parts = base.replace('powernode_mcp_session_', '').replace('.txt', '') + try: + pid = int(parts) + except ValueError: + continue + if my_instance and str(pid) == str(my_instance): + continue + try: + os.kill(pid, 0) + except OSError: + continue # Dead process — claim is stale + try: + with open(f) as fh: + token = fh.read().strip() + if token: + claimed[token] = pid + except (IOError, OSError): + continue + +# Pick first unclaimed session (newest first, already sorted by server) +for s in sessions: + token = s.get('session_token', '') + if token and token not in claimed: + display = s.get('display_name', '') or '' + print(token + '\t' + display) + sys.exit(0) + +sys.exit(1) +" "$discover_json" "$my_instance" 2>/dev/null +} + +# Create a new MCP session via the initialize handshake. +# NOTE: No longer used as automatic fallback in mcp_ensure_session() because it +# creates a session with clientInfo.name "powernode-helper" instead of the real +# Claude Code identity. Kept for manual debugging use only. +# Returns tab-separated "session_token\tdisplay_name" on stdout. +_mcp_create_session() { + local token="$1" + local init_payload + init_payload=$(python3 -c " +import json +print(json.dumps({ + 'jsonrpc': '2.0', + 'id': 'init-helper', + 'method': 'initialize', + 'params': { + 'protocolVersion': '2025-03-26', + 'capabilities': {}, + 'clientInfo': { + 'name': 'powernode-helper', + 'version': '1.0' + } + } +})) +" 2>/dev/null) || return 1 + + local headers_file="/tmp/powernode_mcp_init_headers_$$.txt" + local init_response + init_response=$(curl -sS -D "$headers_file" -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$init_payload" \ + "$MCP_ENDPOINT" 2>/dev/null) || { rm -f "$headers_file"; return 1; } + + local session_id + session_id=$(grep -i '^mcp-session-id:' "$headers_file" 2>/dev/null | tr -d '\r' | sed 's/^[^:]*: *//') + rm -f "$headers_file" + + if [[ -z "$session_id" || ${#session_id} -lt 10 ]]; then + return 1 + fi + + # Complete the handshake + local notif_payload='{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' + curl -sS -X POST \ + -H "Authorization: Bearer $token" \ + -H "Mcp-Session-Id: $session_id" \ + -H "Content-Type: application/json" \ + -d "$notif_payload" \ + "$MCP_ENDPOINT" >/dev/null 2>&1 || true + + echo "${session_id} powernode-helper" +} + +# Discover an existing MCP session via session/discover. +# Returns failure if no sessions are available yet (daemon retries with backoff). +# Caches the session token to MCP_SESSION_FILE (per-instance path). +mcp_ensure_session() { + # Reuse cached session if fresh (< 1 hour) AND not a placeholder "powernode-helper" session. + # Placeholder sessions are created by _mcp_create_session() before the real Claude Code + # MCP client initializes — they have the wrong identity and should be upgraded. + if [[ -f "$MCP_SESSION_FILE" && -s "$MCP_SESSION_FILE" ]]; then + local age + age=$(( $(date +%s) - $(stat -c%Y "$MCP_SESSION_FILE") )) + if (( age < 3600 )); then + local cached_name + cached_name=$(cat "$MCP_SESSION_NAME_FILE" 2>/dev/null) + if [[ "$cached_name" != *"powernode-helper"* ]]; then + cat "$MCP_SESSION_FILE" + return 0 + fi + # Fall through to re-discover a real session (upgrade from placeholder) + fi + fi + + local token + token=$(mcp_token) || return 1 + + # Discover existing sessions instead of creating a new one + local discover_payload='{"jsonrpc":"2.0","id":"discover-helper","method":"session/discover","params":{}}' + local discover_response + discover_response=$(curl -sS -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$discover_payload" \ + "$MCP_ENDPOINT" 2>/dev/null) || return 1 + + local picked + picked=$(_mcp_pick_unclaimed_session "$discover_response") || { + # No discoverable sessions yet — fail gracefully. + # The daemon loop retries with exponential backoff, and PostToolUse hook + # (mcp-sse-autostart.sh) will discover the real session once Claude Code's + # MCP client initializes. We intentionally do NOT fall back to + # _mcp_create_session() here because it creates a competing session with + # the wrong identity ("powernode-helper" instead of "Claude Code #N"). + return 1 + } + + local session_token display_name + IFS=$'\t' read -r session_token display_name <<< "$picked" + + if [[ -n "$session_token" && ${#session_token} -gt 10 ]]; then + echo -n "$session_token" > "$MCP_SESSION_FILE" + [[ -n "$display_name" ]] && echo -n "$display_name" > "$MCP_SESSION_NAME_FILE" + # Auto-start per-instance SSE daemon for this session + mcp_ensure_daemon 2>/dev/null || true + echo "$session_token" + return 0 + else + return 1 + fi +} + +# Get or create an MCP session token. Returns session token on stdout. +# Uses per-instance session only (no shared fallback in per-instance mode). +mcp_session() { + local result + result=$(mcp_ensure_session 2>/dev/null) + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + echo "ERROR: No MCP session available. Run: source .claude/hooks/mcp-helper.sh && mcp_ensure_session" >&2 + return 1 +} + +# --- Tool Invocation --- + +# Call any platform.* MCP tool. +# Usage: mcp_call "platform.knowledge_health" '{"key": "value"}' +mcp_call() { + local tool_name="$1" + local args="$2" + : "${args:="{}"}" + + local token session_id + token=$(mcp_token) || return 1 + session_id=$(mcp_session) || return 1 + + local request_id + request_id="$(date +%s)-$$" + + local payload + payload=$(python3 -c " +import json, sys +print(json.dumps({ + 'jsonrpc': '2.0', + 'id': sys.argv[3], + 'method': 'tools/call', + 'params': { + 'name': sys.argv[1], + 'arguments': json.loads(sys.argv[2]) + } +})) +" "$tool_name" "$args" "$request_id" 2>/dev/null) || { + echo "ERROR: Failed to build JSON payload" >&2 + return 1 + } + + local response + response=$(curl -sS \ + -X POST \ + -H "Authorization: Bearer $token" \ + -H "Mcp-Session-Id: $session_id" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$payload" \ + "$MCP_ENDPOINT" 2>/dev/null) + + # Session-expired detection and single retry + local is_session_error + is_session_error=$(echo "$response" | python3 -c " +import sys, json +try: + r = json.load(sys.stdin) + msg = r.get('error', {}).get('message', '').lower() + print('yes' if 'session' in msg and any(w in msg for w in ['expired','not found','invalid']) else '') +except: print('') +" 2>/dev/null) + + if [[ "$is_session_error" == "yes" ]]; then + rm -f "$MCP_SESSION_FILE" + session_id=$(mcp_session) || return 1 + response=$(curl -sS -X POST \ + -H "Authorization: Bearer $token" \ + -H "Mcp-Session-Id: $session_id" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$payload" \ + "$MCP_ENDPOINT" 2>/dev/null) + fi + + # Pretty-print if python3 available, raw otherwise + if command -v python3 &>/dev/null; then + echo "$response" | python3 -m json.tool 2>/dev/null || echo "$response" + else + echo "$response" + fi +} + +# --- Daemon Management --- + +# Ensure a per-instance SSE daemon is running for this Claude Code session. +# Called automatically after session creation to keep the SSE stream alive. +mcp_ensure_daemon() { + local pid_file="/tmp/powernode_sse_daemon_${MCP_INSTANCE_ID}.pid" + + # Already running? + if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file" 2>/dev/null)" 2>/dev/null; then + return 0 + fi + + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local daemon_script="${script_dir}/workspace-sse-daemon.sh" + + if [[ ! -x "$daemon_script" ]]; then + echo "WARN: Daemon script not found at $daemon_script" >&2 + return 1 + fi + + # Start per-instance daemon in background + INSTANCE_ID="$MCP_INSTANCE_ID" "$daemon_script" start >/dev/null 2>&1 & + disown 2>/dev/null || true + + # Brief wait for PID file to appear + local tries=0 + while (( tries < 10 )); do + if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file" 2>/dev/null)" 2>/dev/null; then + return 0 + fi + sleep 0.3 + (( tries++ )) + done + + echo "WARN: Daemon started but PID file not yet confirmed" >&2 + return 0 +} + +# --- Convenience Wrappers --- + +# Quick health check +mcp_health() { + mcp_call "platform.knowledge_health" '{}' +} + +# Query learnings by category +mcp_learnings() { + local query="${1:-}" + local category="${2:-}" + local args="{}" + if [[ -n "$query" && -n "$category" ]]; then + args="{\"query\": \"$query\", \"category\": \"$category\"}" + elif [[ -n "$query" ]]; then + args="{\"query\": \"$query\"}" + fi + mcp_call "platform.query_learnings" "$args" +} + +# Search shared knowledge +mcp_search() { + local query="$1" + mcp_call "platform.search_knowledge" "{\"query\": \"$query\"}" +} diff --git a/.claude/hooks/mcp-sse-autoconnect.sh b/.claude/hooks/mcp-sse-autoconnect.sh new file mode 100755 index 000000000..cf0cda45a --- /dev/null +++ b/.claude/hooks/mcp-sse-autoconnect.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# SessionStart hook: auto-starts the workspace SSE daemon when Claude Code launches. +# Backgrounds the bootstrap to stay within the 3s hook timeout. Uses a one-shot +# marker to prevent re-entry when /clear re-fires SessionStart. +# +# Creates its own MCP session via initialize if none are discoverable (Claude Code +# lazily connects to MCP — no session exists until the first tool call). The server +# allows multiple concurrent sessions per OAuth app, so this is safe. +# +# PostToolUse hook (mcp-sse-autostart.sh) remains as fallback. + +INSTANCE_ID="${PPID}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PPID_PARENT=$(ps -p "$PPID" -o ppid= 2>/dev/null | tr -d ' ') + +PID_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID}.pid" +MARKER_FILE="/tmp/powernode_autoconnect_${INSTANCE_ID}.attempted" + +# Fast path: daemon already running for this PID or a sibling +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then + exit 0 +fi +if [[ -n "$PPID_PARENT" && "$PPID_PARENT" != "1" ]]; then + for spid in $(pgrep -P "$PPID_PARENT" -x claude 2>/dev/null); do + spf="/tmp/powernode_sse_daemon_${spid}.pid" + if [[ -f "$spf" ]] && kill -0 "$(cat "$spf" 2>/dev/null)" 2>/dev/null; then + exit 0 + fi + done +fi + +# One-shot guard: /clear re-fires SessionStart +if [[ -f "$MARKER_FILE" ]]; then + exit 0 +fi + +# Clean stale markers from dead PIDs +for f in /tmp/powernode_autoconnect_*.attempted; do + [[ -f "$f" ]] || continue + stale_pid=$(basename "$f" | sed 's/powernode_autoconnect_//;s/\.attempted//') + if [[ "$stale_pid" =~ ^[0-9]+$ ]] && ! kill -0 "$stale_pid" 2>/dev/null; then + rm -f "$f" + fi +done + +touch "$MARKER_FILE" + +# Background subshell: resolve PID, create/discover session, start daemon. +( + LOG_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID}.log" + _log() { echo "[$(date -Iseconds)] Autoconnect: $*" >> "$LOG_FILE"; } + + _update_instance() { + local new_pid="$1" method="$2" + _log "Resolved instance via ${method}: $new_pid (was $INSTANCE_ID)" + INSTANCE_ID="$new_pid" + export MCP_INSTANCE_ID="$INSTANCE_ID" + LOG_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID}.log" + touch "/tmp/powernode_autoconnect_${INSTANCE_ID}.attempted" + } + + _resolve_instance() { + # Original PPID still alive — keep it + if kill -0 "$INSTANCE_ID" 2>/dev/null; then + return 0 + fi + + # Poll for a claude child of the ancestor (the session process PostToolUse uses) + if [[ -n "$PPID_PARENT" && "$PPID_PARENT" != "1" ]]; then + local polls=0 + while (( polls < 20 )); do + local real_pid + real_pid=$(pgrep -P "$PPID_PARENT" -x claude 2>/dev/null | head -1) || true + if [[ -n "$real_pid" ]]; then + _update_instance "$real_pid" "sibling-poll(${polls})" + return 0 + fi + sleep 0.5 + (( polls++ )) || true + done + + # No child — ancestor itself may be the session process + local ancestor_comm + ancestor_comm=$(ps -p "$PPID_PARENT" -o comm= 2>/dev/null) || true + if [[ "$ancestor_comm" == "claude" ]] && kill -0 "$PPID_PARENT" 2>/dev/null; then + _update_instance "$PPID_PARENT" "ancestor-fallback" + return 0 + fi + fi + + # Last resort: find inner claude (parent is also claude) without a daemon + local pids pid + pids=$(pgrep -u "$(id -u)" -x claude 2>/dev/null) || true + for pid in $pids; do + local ppid_of parent_comm + ppid_of=$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ') || true + parent_comm=$(ps -p "$ppid_of" -o comm= 2>/dev/null) || true + if [[ "$parent_comm" == "claude" ]]; then + local opf="/tmp/powernode_sse_daemon_${pid}.pid" + if [[ -f "$opf" ]] && kill -0 "$(cat "$opf" 2>/dev/null)" 2>/dev/null; then + continue # already has a daemon + fi + _update_instance "$pid" "inner-claude-scan" + return 0 + fi + done + + _log "Could not resolve Claude PID (PPID $INSTANCE_ID dead, ancestor $PPID_PARENT)" + return 1 + } + + export MCP_INSTANCE_ID="$INSTANCE_ID" + source "${SCRIPT_DIR}/mcp-helper.sh" + set +eo pipefail + + _log "Started (PPID=$INSTANCE_ID, ancestor=$PPID_PARENT)" + + sleep 3 + + # Resolve the real Claude PID + if ! _resolve_instance; then + _log "ABORTED — cannot determine session PID" + exit 1 + fi + + # Re-source helper with corrected INSTANCE_ID + source "${SCRIPT_DIR}/mcp-helper.sh" + set +eo pipefail + + _log "Instance: $INSTANCE_ID" + + # Check if daemon already started by PostToolUse during our sleep + pf="/tmp/powernode_sse_daemon_${INSTANCE_ID}.pid" + if [[ -f "$pf" ]] && kill -0 "$(cat "$pf" 2>/dev/null)" 2>/dev/null; then + _log "Daemon already running — exiting" + exit 0 + fi + + # Session discovery/creation + daemon start (3 attempts, 5s apart) + for attempt in 1 2 3; do + _log "Attempt ${attempt}: session bootstrap..." + if mcp_ensure_session >/dev/null 2>&1; then + _log "SUCCESS on attempt ${attempt} — daemon started for $INSTANCE_ID" + exit 0 + fi + _log "Attempt ${attempt} failed" + + # Check if PostToolUse started the daemon while we waited + if [[ -f "$pf" ]] && kill -0 "$(cat "$pf" 2>/dev/null)" 2>/dev/null; then + _log "Daemon started by PostToolUse — exiting" + exit 0 + fi + + sleep 5 + done + + _log "Session not yet available — starting daemon for background discovery" + mcp_ensure_daemon 2>/dev/null || _log "WARN: daemon start failed" +) >/dev/null 2>&1 & +disown 2>/dev/null || true + +exit 0 diff --git a/.claude/hooks/mcp-sse-autostart.sh b/.claude/hooks/mcp-sse-autostart.sh new file mode 100755 index 000000000..3e9b05ee1 --- /dev/null +++ b/.claude/hooks/mcp-sse-autostart.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# PostToolUse hook: auto-starts the workspace SSE daemon on first Powernode MCP tool call. +# Triggered by any mcp__powernode__* tool use. Idempotent — exits immediately if daemon is live. +# +# Uses flock to prevent concurrent bootstrap when multiple MCP tools fire in quick +# succession. The lock is held by the backgrounded subshell until bootstrap completes, +# so subsequent hook invocations exit instantly instead of spawning duplicate daemons. + +INSTANCE_ID="${PPID}" +PID_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID}.pid" +LOCK_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID}.lock" + +# Fast path: daemon already running +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then + exit 0 +fi + +# Acquire non-blocking lock — if another hook invocation is already bootstrapping, exit. +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + exit 0 +fi + +# Double-check after acquiring lock (daemon may have started between fast-path and lock) +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then + exec 9>&- + exit 0 +fi + +# Bootstrap session + daemon in background to avoid blocking the hook. +# The subshell inherits fd 9, keeping the flock held until bootstrap completes. +# This prevents any concurrent hook invocation from entering the critical section. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +( + export MCP_INSTANCE_ID="$INSTANCE_ID" + source "${SCRIPT_DIR}/mcp-helper.sh" + mcp_ensure_session >/dev/null 2>&1 + # fd 9 (flock) is released when this subshell exits +) & +disown 2>/dev/null || true + +# Close our copy of the lock fd — the backgrounded subshell still holds it +# via inherited file descriptor, so the lock remains active. +exec 9>&- + +exit 0 diff --git a/.claude/hooks/mcp-token-env.sh b/.claude/hooks/mcp-token-env.sh new file mode 100755 index 000000000..bb0ee9613 --- /dev/null +++ b/.claude/hooks/mcp-token-env.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Pre-loads POWERNODE_MCP_TOKEN for .mcp.json header interpolation. +# Used as a fallback when Claude Code's automatic OAuth flow can't complete. +# +# Reuses the token/session infrastructure from mcp-helper.sh and workspace-sse-daemon.sh. +# Token is cached for 25 minutes (1500s), regenerated via rails runner. + +set -eo pipefail + +MCP_SERVER_DIR="${POWERNODE_ROOT:-/opt/powernode}/server" +MCP_TOKEN_FILE="/tmp/powernode_mcp_token.txt" +MCP_IDS_CACHE_FILE="/tmp/powernode_sse_ids_cache.txt" +MAX_AGE=1500 # 25 minutes + +# Return cached token if fresh +if [[ -f "$MCP_TOKEN_FILE" && -s "$MCP_TOKEN_FILE" ]]; then + age=$(( $(date +%s) - $(stat -c%Y "$MCP_TOKEN_FILE" 2>/dev/null || echo 0) )) + if (( age < MAX_AGE )); then + echo "POWERNODE_MCP_TOKEN=$(cat "$MCP_TOKEN_FILE")" + exit 0 + fi +fi + +# Need cached identifiers from SSE daemon +if [[ ! -f "$MCP_IDS_CACHE_FILE" || ! -s "$MCP_IDS_CACHE_FILE" ]]; then + echo "POWERNODE_MCP_TOKEN=" # empty — will trigger 401 and OAuth fallback + exit 0 +fi + +source "$MCP_IDS_CACHE_FILE" + +# Generate fresh token via rails runner +token=$(cd "$MCP_SERVER_DIR" && bin/rails runner " +app = Doorkeeper::Application.find('$OAUTH_APP_ID') +token = Doorkeeper::AccessToken.create!( + application: app, + resource_owner_id: '$RESOURCE_OWNER_ID', + scopes: 'read write', + expires_in: 7200, + use_refresh_token: false +) +print token.plaintext_token || token.token +" 2>/dev/null) || true + +if [[ -n "$token" && ${#token} -gt 10 ]]; then + echo -n "$token" > "$MCP_TOKEN_FILE" + echo "POWERNODE_MCP_TOKEN=$token" +else + echo "POWERNODE_MCP_TOKEN=" # empty — graceful degradation +fi diff --git a/.claude/hooks/ruby-convention-check.sh b/.claude/hooks/ruby-convention-check.sh new file mode 100755 index 000000000..93c1e36bb --- /dev/null +++ b/.claude/hooks/ruby-convention-check.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# Advisory hook: checks Ruby naming conventions, FK prefixes, JSON defaults, and migration indexes +# Exit 0 always (advisory) — warnings printed to stderr + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty') + +[[ "$FILE_PATH" != *.rb ]] && exit 0 +[[ ! -f "$FILE_PATH" ]] && exit 0 + +WARNINGS="" + +# --- Namespace class_name checks --- +# All namespaced models MUST use :: separator, not flat concatenation +# Check for flat namespace references in class_name strings and class definitions +NAMESPACES="Ai|Devops|BaaS|Baas|Chat|KnowledgeBase|FileManagement|SupplyChain|Monitoring|Marketplace|Review|Account|DataManagement" + +# Check class_name: "FlatName" patterns (e.g., class_name: "AiAgentTeam" instead of "Ai::AgentTeam") +while IFS= read -r line; do + [[ -z "$line" ]] && continue + WARNINGS="${WARNINGS}${line}\n" +done < <(grep -nP "class_name:\s*['\"]($NAMESPACES)[A-Z][a-zA-Z]*['\"]" "$FILE_PATH" 2>/dev/null | grep -vP "::") + +# Check class definitions (e.g., class AiAgentTeam instead of class Ai::AgentTeam) +while IFS= read -r line; do + [[ -z "$line" ]] && continue + WARNINGS="${WARNINGS}${line}\n" +done < <(grep -nP "^\s*class\s+($NAMESPACES)[A-Z][a-zA-Z]+" "$FILE_PATH" 2>/dev/null | grep -vP "::") + +# --- FK prefix checks for namespaced models --- +# Ai:: models should use ai_ prefix on FKs +if echo "$FILE_PATH" | grep -qP "(models/ai/|migrate/)"; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + # Check for bare agent_id, provider_id, workflow_id without ai_ prefix in Ai:: context + if echo "$line" | grep -qP "(belongs_to|has_many|has_one|references)" && echo "$line" | grep -qP "class_name:.*Ai::"; then + if echo "$line" | grep -qP "foreign_key:.*['\"](?!ai_)" 2>/dev/null; then + WARNINGS="${WARNINGS}Warning: Ai:: association should use ai_ FK prefix: ${line}\n" + fi + fi + done < <(grep -nP "(belongs_to|has_many|has_one)" "$FILE_PATH" 2>/dev/null) +fi + +# Devops:: models should use ci_cd_ prefix on FKs +if echo "$FILE_PATH" | grep -qP "(models/devops/|models/ci_cd/|migrate/)"; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + if echo "$line" | grep -qP "class_name:.*Devops::" && echo "$line" | grep -qP "foreign_key:" ; then + if ! echo "$line" | grep -qP "foreign_key:.*ci_cd_" 2>/dev/null; then + WARNINGS="${WARNINGS}Warning: Devops:: association should use ci_cd_ FK prefix: ${line}\n" + fi + fi + done < <(grep -nP "(belongs_to|has_many|has_one)" "$FILE_PATH" 2>/dev/null) +fi + +# BaaS:: models should use baas_ prefix on FKs +if echo "$FILE_PATH" | grep -qP "(models/baas/|models/ba_as/|migrate/)"; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + if echo "$line" | grep -qP "class_name:.*BaaS::" && echo "$line" | grep -qP "foreign_key:" ; then + if ! echo "$line" | grep -qP "foreign_key:.*baas_" 2>/dev/null; then + WARNINGS="${WARNINGS}Warning: BaaS:: association should use baas_ FK prefix: ${line}\n" + fi + fi + done < <(grep -nP "(belongs_to|has_many|has_one)" "$FILE_PATH" 2>/dev/null) +fi + +# --- JSON default check --- +# Warn on default: {} (should be default: -> { {} }) +while IFS= read -r line; do + [[ -z "$line" ]] && continue + WARNINGS="${WARNINGS}Warning: Use lambda default: -> { {} } instead of literal default: ${line}\n" +done < <(grep -nP "default:\s*\{\s*\}" "$FILE_PATH" 2>/dev/null | grep -vP "default:\s*->\s*\{") + +# --- class_name/foreign_key pairing --- +# Warn when class_name: appears without foreign_key: on the same line +while IFS= read -r line; do + [[ -z "$line" ]] && continue + WARNINGS="${WARNINGS}Warning: class_name: without foreign_key: — always pair them: ${line}\n" +done < <(grep -nP "class_name:" "$FILE_PATH" 2>/dev/null | grep -vP "foreign_key:") + +# --- Migration index check --- +# In migration files, warn if add_index follows t.references for the same column +if echo "$FILE_PATH" | grep -qP "db/migrate/"; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + COL=$(echo "$line" | grep -oP 'add_index\s+:\w+,\s*:(\w+)' | grep -oP ':\w+$' | tail -1) + if [[ -n "$COL" ]]; then + REF_COL=$(echo "$COL" | sed 's/^://' | sed 's/_id$//') + if grep -qP "t\.references\s+:$REF_COL" "$FILE_PATH" 2>/dev/null; then + WARNINGS="${WARNINGS}Warning: Redundant add_index for t.references column ${COL} — references already creates an index. Use inline index: option on the references declaration for customization (e.g., unique): ${line}\n" + fi + fi + done < <(grep -nP "add_index" "$FILE_PATH" 2>/dev/null) +fi + +# --- Output warnings --- +if [[ -n "$WARNINGS" ]]; then + echo -e "Ruby convention warnings in $FILE_PATH:" >&2 + echo -e "$WARNINGS" >&2 +fi +exit 0 diff --git a/.claude/hooks/ruby-syntax-check.sh b/.claude/hooks/ruby-syntax-check.sh new file mode 100755 index 000000000..1981c43d7 --- /dev/null +++ b/.claude/hooks/ruby-syntax-check.sh @@ -0,0 +1,19 @@ +#!/bin/bash +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +[[ "$FILE_PATH" != *.rb ]] && exit 0 +[[ ! -f "$FILE_PATH" ]] && exit 0 + +OUTPUT=$(ruby -c "$FILE_PATH" 2>&1) +if [[ $? -ne 0 ]]; then + echo "Ruby syntax error in $FILE_PATH: $OUTPUT" >&2 + exit 2 +fi + +# Advisory: check for frozen_string_literal pragma +FIRST_LINE=$(head -1 "$FILE_PATH") +if [[ "$FIRST_LINE" != "# frozen_string_literal: true" ]]; then + echo "Warning: $FILE_PATH missing '# frozen_string_literal: true' pragma" >&2 +fi +exit 0 diff --git a/.claude/hooks/typescript-check.sh b/.claude/hooks/typescript-check.sh new file mode 100755 index 000000000..145d38c56 --- /dev/null +++ b/.claude/hooks/typescript-check.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Blocking hook: runs tsc --noEmit after TypeScript file edits +# Exit 2 = blocking (Claude must fix), Exit 0 = pass + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty') + +# Only check .ts/.tsx files +[[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]] && exit 0 +# Only check core frontend source files (not enterprise submodule) +[[ "$FILE_PATH" != *frontend/src/* ]] && exit 0 +[[ "$FILE_PATH" == *enterprise/frontend/* ]] && exit 0 +[[ ! -f "$FILE_PATH" ]] && exit 0 +# Skip test/spec files — type errors there are less critical +BASENAME=$(basename "$FILE_PATH") +[[ "$BASENAME" == *".test."* || "$BASENAME" == *".spec."* ]] && exit 0 + +# Find frontend directory — always use the core frontend +FRONTEND_DIR=$(echo "$FILE_PATH" | sed 's|/frontend/src/.*|/frontend|') +[[ ! -d "$FRONTEND_DIR" ]] && exit 0 + +# Use project-local tsc, not npx (avoids npx resolution failures) +TSC="$FRONTEND_DIR/node_modules/.bin/tsc" +[[ ! -x "$TSC" ]] && exit 0 + +OUTPUT=$(cd "$FRONTEND_DIR" && "$TSC" --noEmit 2>&1) +EXIT_CODE=$? + +if [[ $EXIT_CODE -ne 0 ]]; then + echo "TypeScript type errors found after editing $FILE_PATH:" >&2 + echo "$OUTPUT" | head -20 >&2 + TOTAL_ERRORS=$(echo "$OUTPUT" | grep -c "^.*([0-9]*,[0-9]*): error TS" || true) + if [[ "$TOTAL_ERRORS" -gt 20 ]]; then + echo "... ($TOTAL_ERRORS total errors, showing first 20)" >&2 + fi + exit 2 +fi +exit 0 diff --git a/.claude/hooks/workspace-format-inbox.sh b/.claude/hooks/workspace-format-inbox.sh new file mode 100755 index 000000000..6c2ac8838 --- /dev/null +++ b/.claude/hooks/workspace-format-inbox.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Shared inbox formatter — reads unread workspace messages from the JSONL inbox +# and outputs them as context for Claude Code. +# +# Sourced by both: +# - workspace-messages.sh (UserPromptSubmit hook) +# - workspace-stop-check.sh (Stop hook) +# +# Expects: CLAUDE_PID set by the caller (identifies the inbox file) +# Output: XML on stdout if unread messages exist +# +# Design: READ-ONLY inbox. Read-state tracked in a separate file to avoid +# race conditions with the SSE daemon writing to the inbox concurrently. + +# Resolve the actual Claude Code PID by walking up the process tree. +# When Claude Code spawns hooks, PPID may point to an intermediate shell, +# not the Claude Code process itself. The daemon keys its inbox by the +# Claude Code PID, so we must find it reliably. +_resolve_claude_pid() { + local pid="$1" + # Walk up the process tree looking for a 'claude' process + while [[ -n "$pid" && "$pid" != "1" && "$pid" != "0" ]]; do + local comm + comm=$(ps -p "$pid" -o comm= 2>/dev/null) || break + if [[ "$comm" == "claude" ]]; then + echo "$pid" + return 0 + fi + pid=$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ') || break + done + return 1 +} + +# Try direct PID first, then walk process tree, then scan for active daemon +RESOLVED_PID="" +if [[ -f "/tmp/powernode_workspace_inbox_${CLAUDE_PID}.jsonl" ]]; then + RESOLVED_PID="$CLAUDE_PID" +elif RESOLVED_PID=$(_resolve_claude_pid "$CLAUDE_PID"); then + : # found via process tree walk +else + # Fallback: find inbox from the active daemon's PID file + for pf in /tmp/powernode_sse_daemon_*.pid; do + [[ -f "$pf" ]] || continue + dpid=$(cat "$pf" 2>/dev/null) || continue + kill -0 "$dpid" 2>/dev/null || continue + instance=$(basename "$pf" | sed 's/powernode_sse_daemon_//;s/\.pid//') + if [[ -f "/tmp/powernode_workspace_inbox_${instance}.jsonl" ]]; then + RESOLVED_PID="$instance" + break + fi + done +fi + +CLAUDE_PID="${RESOLVED_PID:-$CLAUDE_PID}" +INBOX_FILE="/tmp/powernode_workspace_inbox_${CLAUDE_PID}.jsonl" +READ_STATE="/tmp/powernode_workspace_read_${CLAUDE_PID}.ids" + +# Fast exit if no inbox +if [[ ! -f "$INBOX_FILE" ]]; then + return 0 2>/dev/null || exit 0 +fi + +# Capture formatted output into a variable first — prevents orphaned +# tags if python3 fails mid-output. +FORMATTED=$(timeout 2 python3 -c " +import json, os, sys + +inbox_path = '$INBOX_FILE' +read_state_path = '$READ_STATE' + +# Load seen message IDs +seen_ids = set() +if os.path.exists(read_state_path): + with open(read_state_path, 'r') as f: + for line in f: + line = line.strip() + if line: + seen_ids.add(line) + +# Parse inbox — collect unread events (IDs not in read-state) +unread = [] +new_ids = [] +with open(inbox_path, 'r') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + evt = json.loads(line) + except json.JSONDecodeError: + continue + msg_id = evt.get('message_id', '') + if msg_id and msg_id not in seen_ids: + unread.append(evt) + new_ids.append(msg_id) + +if not unread: + sys.exit(0) + +# Format output +conv_ids = set() +lines = [] +lines.append(f'You have {len(unread)} unread workspace message(s):') +lines.append('') + +for evt in unread: + ts = evt.get('ts', '?') + time_part = ts.split('T')[1][:8] if 'T' in ts else ts + event_type = evt.get('event', 'message') + sender = evt.get('sender', 'Unknown') + content = evt.get('content', '') + msg_id = evt.get('message_id', '') + conv_id = evt.get('conversation_id', '') + workspace = evt.get('workspace', '') + + if conv_id: + conv_ids.add(conv_id) + + label = '@mentioned you' if event_type == 'mention' else 'said' + ws_label = f' in {workspace}' if workspace else '' + + lines.append(f'[{time_part}] {sender} {label}{ws_label}:') + lines.append(f' \"{content}\"') + if msg_id: + details = f'message_id: {msg_id}' + if conv_id: + details += f', conversation: {conv_id}' + lines.append(f' ({details})') + lines.append('') + +if conv_ids: + lines.append('IMPORTANT: If these messages require a response, reply using the platform.send_message MCP tool:') + lines.append(' tool: platform.send_message') + lines.append(' params: conversation_id=, message=') + for cid in sorted(conv_ids): + lines.append(f' Active conversation: {cid}') + +print('\n'.join(lines)) + +# Append new IDs to read-state file +if new_ids: + all_ids = list(seen_ids) + new_ids + # Bound to last 200 IDs via atomic rename + if len(all_ids) > 200: + all_ids = all_ids[-200:] + tmp_path = read_state_path + '.tmp' + with open(tmp_path, 'w') as f: + for mid in all_ids: + f.write(mid + '\n') + os.rename(tmp_path, read_state_path) +" 2>/dev/null || true) + +# Only emit XML wrapper if there's actual content +if [[ -n "$FORMATTED" ]]; then + echo "" + echo "$FORMATTED" + echo "" +fi diff --git a/.claude/hooks/workspace-messages.sh b/.claude/hooks/workspace-messages.sh new file mode 100755 index 000000000..f029ea26b --- /dev/null +++ b/.claude/hooks/workspace-messages.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# UserPromptSubmit hook — delivers unread workspace messages as context. +# +# Timeout: 3s (runs on every prompt, must be fast) +# Output: stdout text injected into Claude's context +# No output = no context injected (clean no-op when no messages) +# +# Rules: +# - Must NEVER exit non-zero (crashes Claude Code) +# - Must not spawn background processes (FD inheritance → hangs) +# - Stdin closed immediately (no blocking on pipe) + +exec 0 [mentions_json] +# +# Uses the daemon's OAuth token and MCP session for authentication. +# The session file is keyed to Claude's PID — we walk the process tree +# upward from $$ to find the ancestor `claude` process. + +set -eo pipefail + +# --- Resolve Claude's PID by walking the process tree upward --- +_resolve_claude_pid() { + local pid=$$ + while [ "$pid" -gt 1 ] 2>/dev/null; do + local comm + comm=$(ps -p "$pid" -o comm= 2>/dev/null) || break + [ "$comm" = "claude" ] && echo "$pid" && return 0 + pid=$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ') + [ -z "$pid" ] && break + done + return 1 +} + +PLATFORM_URL="${POWERNODE_URL:-http://localhost:3000}" +MCP_ENDPOINT="${PLATFORM_URL}/api/v1/mcp/message" +TOKEN_FILE="/tmp/powernode_mcp_token.txt" + +# Session discovery: ancestor walk → glob fallback +CLAUDE_PID=$(_resolve_claude_pid 2>/dev/null || true) +if [[ -n "$CLAUDE_PID" && -f "/tmp/powernode_mcp_session_${CLAUDE_PID}.txt" ]]; then + SESSION_FILE="/tmp/powernode_mcp_session_${CLAUDE_PID}.txt" +else + # Glob fallback: pick the first available session file + SESSION_FILE="" + for f in /tmp/powernode_mcp_session_*.txt; do + [[ -f "$f" && -s "$f" ]] && SESSION_FILE="$f" && break + done + if [[ -z "$SESSION_FILE" ]]; then + echo "Error: No MCP session file found. Start the SSE daemon first." >&2 + exit 1 + fi +fi + +CONVERSATION_ID="$1" +MESSAGE="$2" +MENTIONS_JSON="${3:-}" + +if [[ -z "$CONVERSATION_ID" || -z "$MESSAGE" ]]; then + echo "Usage: $0 [mentions_json]" >&2 + exit 1 +fi + +if [[ ! -f "$TOKEN_FILE" || ! -f "$SESSION_FILE" ]]; then + echo "Error: SSE daemon token/session not found. Start the daemon first." >&2 + exit 1 +fi + +TOKEN=$(cat "$TOKEN_FILE") +SESSION=$(cat "$SESSION_FILE") + +# Build JSON payload safely with python3 (handles all escaping) +PAYLOAD=$(python3 -c " +import json, sys +args = { + 'action': 'send_message', + 'conversation_id': sys.argv[1], + 'message': sys.argv[2] +} +mentions = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None +if mentions: + args['mentions'] = json.loads(mentions) +print(json.dumps({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'tools/call', + 'params': { + 'name': 'platform.send_message', + 'arguments': args + } +})) +" "$CONVERSATION_ID" "$MESSAGE" "$MENTIONS_JSON") + +RESPONSE=$(curl -s -X POST "$MCP_ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Mcp-Session-Id: $SESSION" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" 2>&1) + +# Extract success/error from response +python3 -c " +import json, sys +try: + r = json.loads(sys.argv[1]) + if 'error' in r: + print(f'Error: {r[\"error\"][\"message\"]}') + sys.exit(1) + result = r.get('result', {}) + content = result.get('content', [{}]) + if content: + data = json.loads(content[0].get('text', '{}')) + if data.get('success'): + print(f'Sent to {data[\"conversation_id\"]} as {data[\"sender\"]} (message_id: {data[\"message_id\"]})') + else: + print(f'Error: {data.get(\"error\", \"Unknown error\")}') + sys.exit(1) +except Exception as e: + print(f'Error parsing response: {e}') + print(sys.argv[1][:200]) + sys.exit(1) +" "$RESPONSE" diff --git a/.claude/hooks/workspace-sse-daemon.sh b/.claude/hooks/workspace-sse-daemon.sh new file mode 100755 index 000000000..26c49b69e --- /dev/null +++ b/.claude/hooks/workspace-sse-daemon.sh @@ -0,0 +1,1248 @@ +#!/usr/bin/env bash +# Workspace SSE Daemon — maintains an SSE connection to the Powernode MCP endpoint +# and writes workspace events (mentions, messages) to a JSONL inbox file. +# +# Usage: +# workspace-sse-daemon.sh start — Start daemon in background +# workspace-sse-daemon.sh stop — Stop running daemon +# workspace-sse-daemon.sh reload — SIGHUP: reconnect SSE with fresh channels +# workspace-sse-daemon.sh status — Show daemon status +# workspace-sse-daemon.sh tail — Tail recent events +# workspace-sse-daemon.sh refresh — Force token refresh + +set -eo pipefail + +# --- Configuration --- +PLATFORM_URL="${POWERNODE_URL:-http://localhost:3000}" +SSE_ENDPOINT="${PLATFORM_URL}/api/v1/mcp/message" +SERVER_DIR="${POWERNODE_ROOT:-/opt/powernode}/server" +CC_CREDENTIALS="${HOME}/.claude/.credentials.json" + +# Detect remote mode: POWERNODE_URL is not localhost +_is_remote() { + [[ "$PLATFORM_URL" != *"localhost"* && "$PLATFORM_URL" != *"127.0.0.1"* ]] +} + +# Extract OAuth token from Claude Code's credentials (remote mode only) +_cc_token() { + [[ -f "$CC_CREDENTIALS" ]] || return 1 + python3 -c " +import json, sys, time +with open(sys.argv[1]) as f: + d = json.load(f) +for key, val in d.get('mcpOAuth', {}).items(): + if key.startswith('powernode'): + expires = val.get('expiresAt', 0) + if expires > time.time() * 1000: + print(val['accessToken'], end='') + sys.exit(0) + else: + sys.exit(1) +sys.exit(1) +" "$CC_CREDENTIALS" 2>/dev/null +} + +# Per-instance mode: INSTANCE_ID is the Claude Code PID that owns this daemon. +# When set, all file paths are keyed by this ID for full session isolation. +# Set via: INSTANCE_ID= workspace-sse-daemon.sh start +INSTANCE_ID="${INSTANCE_ID:-}" + +if [[ -n "$INSTANCE_ID" ]]; then + INBOX_FILE="/tmp/powernode_workspace_inbox_${INSTANCE_ID}.jsonl" + PID_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID}.pid" + LOG_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID}.log" + SESSION_FILE="/tmp/powernode_mcp_session_${INSTANCE_ID}.txt" + SESSION_NAME_FILE="/tmp/powernode_mcp_session_name_${INSTANCE_ID}.txt" + SEEN_FILE="/tmp/powernode_sse_seen_ids_${INSTANCE_ID}.txt" +else + INBOX_FILE="/tmp/powernode_workspace_inbox.jsonl" + PID_FILE="/tmp/powernode_sse_daemon.pid" + LOG_FILE="/tmp/powernode_sse_daemon.log" + SESSION_FILE="/tmp/powernode_sse_session.txt" + SESSION_NAME_FILE="/tmp/powernode_sse_session_name.txt" + SEEN_FILE="/tmp/powernode_sse_seen_ids.txt" +fi + +TOKEN_FILE="/tmp/powernode_mcp_token.txt" # Shared — same OAuth credentials + +# --- Session Discovery --- +# Picks an unclaimed session from the session/discover response. +# Checks /tmp/powernode_mcp_session_*.txt files to see which sessions are +# already claimed by other live Claude Code instances. Returns 0 and prints +# the session token on success, or returns 1 if no unclaimed session available. +_pick_unclaimed_session() { + local discover_json="$1" + local preferred_token="${2:-}" + local my_instance="${INSTANCE_ID:-}" + + python3 -c " +import json, sys, os, glob, signal + +data = json.loads(sys.argv[1]) +my_instance = sys.argv[2] if len(sys.argv) > 2 else '' +preferred = sys.argv[3] if len(sys.argv) > 3 else '' + +sessions = data.get('result', {}).get('sessions', []) +if not sessions: + sys.exit(1) + +# Build set of session tokens already claimed by live processes +claimed = {} +for f in glob.glob('/tmp/powernode_mcp_session_*.txt'): + # Extract PID from filename: powernode_mcp_session_.txt + base = os.path.basename(f) + # Skip name files + if 'name' in base: + continue + parts = base.replace('powernode_mcp_session_', '').replace('.txt', '') + try: + pid = int(parts) + except ValueError: + continue + # Skip our own instance (allow re-claiming) + if my_instance and str(pid) == str(my_instance): + continue + # Check if claimant is alive + try: + os.kill(pid, 0) + except OSError: + continue # Dead process — claim is stale + # Read the claimed session token + try: + with open(f) as fh: + token = fh.read().strip() + if token: + claimed[token] = pid + except (IOError, OSError): + continue + +# Prefer previously-held session to maintain instance affinity +if preferred: + for s in sessions: + token = s.get('session_token', '') + if token == preferred and token not in claimed: + display = s.get('display_name', '') or '' + agent_id = s.get('agent_id', '') or '' + print(token + '\t' + display + '\t' + agent_id) + sys.exit(0) + +# Fall back to first unclaimed session (newest first, already sorted by server) +for s in sessions: + token = s.get('session_token', '') + if token and token not in claimed: + # Print token, display_name, and agent_id tab-separated + display = s.get('display_name', '') or '' + agent_id = s.get('agent_id', '') or '' + print(token + '\t' + display + '\t' + agent_id) + sys.exit(0) + +# All sessions claimed +sys.exit(1) +" "$discover_json" "$my_instance" "$preferred_token" 2>>"$LOG_FILE" +} + +_CURL_PID="" + +MAX_INBOX_LINES=100 +TOKEN_REFRESH_INTERVAL=1800 # 30 minutes +MAX_BACKOFF=30 +NUDGE_COOLDOWN=10 # seconds between tmux nudges (prevents spam) +SSE_READ_TIMEOUT=90 # seconds — server pings every 30s; 3 missed pings = stale connection + +# --- Tmux Injection --- +# Finds the tmux pane running Claude Code and injects the message content +# directly as a prompt. Slash commands (/clear, /commit) are passed through +# as-is so Claude Code handles them natively. +# +# If the user has text in the input, it is saved, cleared, and restored after +# the nudge prompt is submitted. +_last_nudge=0 +PENDING_RESTORE_FILE="/tmp/powernode_nudge_restore.txt" + +nudge_claude() { + local message_content="${1:-}" + local now + now=$(date +%s) + + # Rate-limit: don't inject more often than NUDGE_COOLDOWN seconds + if (( now - _last_nudge < NUDGE_COOLDOWN )); then + log "Inject skipped (cooldown)" + return + fi + + # Find the tmux pane running claude — in per-instance mode, target only our pane + local target + if [[ -n "$INSTANCE_ID" ]]; then + # Find the pane whose child is our specific Claude PID + target=$(tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_pid}' 2>/dev/null \ + | while read -r pane_ref pane_pid; do + pgrep -P "$pane_pid" 2>/dev/null | while read cpid; do + [[ "$cpid" == "$INSTANCE_ID" ]] && echo "$pane_ref" && break 2 + done + done) || true + else + target=$(tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}' 2>/dev/null \ + | grep -m1 ' claude$' \ + | cut -d' ' -f1) || true + fi + + if [[ -z "$target" ]]; then + log "Inject: no tmux pane running claude found" + return + fi + + # Capture the input prompt line from the bottom 6 lines of the pane. + # The TUI prompt is "❯" (U+276F) followed by a non-breaking space (U+00A0), + # so we match on the ❯ character alone and strip "❯" prefix. + # Restrict to tail -6 to avoid matching ❯ in conversation scrollback. + local prompt_line saved_text + prompt_line=$(tmux capture-pane -t "$target" -p | tail -6 | grep -m1 '^❯' || true) + # Strip the prompt prefix: ❯ followed by non-breaking space (U+00A0 = \xc2\xa0) + saved_text=$(printf '%s' "$prompt_line" | sed "s/^❯$(printf '\xc2\xa0')//") + # Trim trailing whitespace + saved_text=$(printf '%s' "$saved_text" | sed 's/[[:space:]]*$//') + + if [[ -n "$saved_text" ]]; then + log "Saving user input for restore: ${saved_text:0:60}" + printf '%s' "$saved_text" > "$PENDING_RESTORE_FILE" + + # Clear the input with Ctrl+U (kill entire line — standard Unix binding) + tmux send-keys -t "$target" C-u 2>/dev/null + sleep 0.1 + fi + + # Pass slash commands through literally; otherwise invoke /workspace. + # The UserPromptSubmit hook injects full workspace context on every prompt. + local prompt + if [[ "$message_content" == /* ]]; then + prompt="$message_content" + else + prompt="/workspace" + fi + + # Send text with -l (literal) to handle special chars, pause, then Enter. + tmux send-keys -t "$target" -l "$prompt" 2>/dev/null && \ + sleep 0.2 && \ + tmux send-keys -t "$target" Enter 2>/dev/null && { + _last_nudge=$now + log "Injected to tmux pane $target: ${prompt:0:60}" + + # Schedule input restoration in background + if [[ -s "$PENDING_RESTORE_FILE" ]]; then + _restore_input_async "$target" & + fi + } || { + log "Inject: tmux send-keys failed for $target" + } +} + +# Waits for Claude to finish processing, then restores saved input text. +# Runs as a background subshell so the main event loop isn't blocked. +_restore_input_async() { + local target="$1" + local max_wait=120 # seconds to wait before giving up + local elapsed=0 + + # First, wait a few seconds for Claude to start processing (prompt disappears) + sleep 5 + + # Then wait for the input prompt to reappear (❯ visible near bottom of pane), + # which means Claude has finished and is ready for input again. + while (( elapsed < max_wait )); do + sleep 3 + elapsed=$((elapsed + 3)) + + # Check if the prompt line is visible — indicates Claude is idle and waiting + local has_prompt + has_prompt=$(tmux capture-pane -t "$target" -p 2>/dev/null | tail -6 | grep -c '^❯' || true) + + if [[ "${has_prompt:-0}" -gt 0 ]] && [[ -s "$PENDING_RESTORE_FILE" ]]; then + local restore_text + restore_text=$(<"$PENDING_RESTORE_FILE") + rm -f "$PENDING_RESTORE_FILE" + + # Small delay to let TUI fully settle + sleep 0.5 + tmux send-keys -t "$target" -l "$restore_text" 2>/dev/null && { + log "Restored user input: ${restore_text:0:60}" + } || { + log "Restore failed: tmux send-keys error" + } + return + fi + done + + # Timed out — clean up without restoring + rm -f "$PENDING_RESTORE_FILE" + log "Restore abandoned (timeout after ${max_wait}s)" +} + +# --- Logging --- +log() { + echo "[$(date -Iseconds)] $*" >> "$LOG_FILE" +} + +log_and_echo() { + local msg="[$(date -Iseconds)] $*" + echo "$msg" >> "$LOG_FILE" + echo "$msg" +} + +# --- Token Management --- +refresh_token() { + # Remote mode: read token from Claude Code's credentials file + if _is_remote; then + log "Refreshing token from Claude Code credentials..." + local cc_token + cc_token=$(_cc_token) || { + log "ERROR: Cannot read Powernode OAuth token from CC credentials. Reconnect via /mcp." + return 1 + } + echo -n "$cc_token" > "$TOKEN_FILE" + log "Token refreshed from CC credentials (${#cc_token} chars)" + return 0 + fi + + # Local mode: generate via rails runner + log "Refreshing OAuth token via rails runner..." + + local ruby_script + ruby_script=$(cat <>"$LOG_FILE") + + if [[ -n "$new_token" && ${#new_token} -gt 10 ]]; then + echo -n "$new_token" > "$TOKEN_FILE" + log "Token refreshed successfully (${#new_token} chars)" + return 0 + else + log "ERROR: Token refresh failed — got empty or short response" + return 1 + fi +} + +get_token() { + # Remote mode: always read fresh from credentials (avoids stale tokens after OAuth rotation) + if _is_remote; then + local cc_token + cc_token=$(_cc_token) || { + # Fall back to cached file if credentials read fails + if [[ -f "$TOKEN_FILE" && -s "$TOKEN_FILE" ]]; then + cat "$TOKEN_FILE" + return 0 + fi + return 1 + } + echo -n "$cc_token" > "$TOKEN_FILE" + echo "$cc_token" + return 0 + fi + + # Local mode: use cached file + if [[ -f "$TOKEN_FILE" && -s "$TOKEN_FILE" ]]; then + cat "$TOKEN_FILE" + else + return 1 + fi +} + +# --- MCP Session Management --- +# In per-instance mode, the daemon reuses the session created by mcp-helper.sh +# (written to SESSION_FILE by mcp_ensure_session). No separate session needed. +# In remote mode, creates a session via HTTP initialize handshake. +# In shared mode (legacy), discovers the CLI's active session from the DB. +ensure_session() { + # Reuse cached session if fresh (< 1 hour) AND not a placeholder "powernode-helper" session. + # Placeholder sessions are created before the real Claude Code MCP client initializes — + # they have the wrong identity and should be upgraded via re-discovery. + if [[ -f "$SESSION_FILE" && -s "$SESSION_FILE" ]]; then + local age + age=$(( $(date +%s) - $(stat -c%Y "$SESSION_FILE") )) + if (( age < 3600 )); then + local cached_name + cached_name=$(cat "$SESSION_NAME_FILE" 2>/dev/null) + if [[ "$cached_name" != *"powernode-helper"* ]]; then + return 0 + fi + log "Upgrading placeholder 'powernode-helper' session — re-discovering real session" + else + log "Session file stale (${age}s old)" + fi + fi + + # Remote mode: discover existing session (created by Claude Code CLI) + if _is_remote; then + log "Remote mode — discovering CLI session via session/discover..." + local token + token=$(get_token) || { log "ERROR: No token for session discovery"; return 1; } + + local discover_payload='{"jsonrpc":"2.0","id":"discover-1","method":"session/discover","params":{}}' + local discover_response + discover_response=$(curl -sS -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$discover_payload" \ + "$SSE_ENDPOINT" 2>>"$LOG_FILE") || { log "ERROR: session/discover request failed"; return 1; } + + local picked + picked=$(_pick_unclaimed_session "$discover_response" "${_PREFERRED_SESSION:-}") || { + log "No unclaimed sessions available — waiting for CLI to connect" + return 1 + } + + local session_token display_name discovered_agent_id + IFS=$'\t' read -r session_token display_name discovered_agent_id <<< "$picked" + + if [[ -n "$session_token" && ${#session_token} -gt 10 ]]; then + echo -n "$session_token" > "$SESSION_FILE" + [[ -n "$display_name" ]] && echo -n "$display_name" > "$SESSION_NAME_FILE" + if [[ -n "$discovered_agent_id" && "$discovered_agent_id" != "${AGENT_ID:-}" ]]; then + log "Agent ID set from discover: ${AGENT_ID:-none} -> $discovered_agent_id" + AGENT_ID="$discovered_agent_id" + fi + log "Discovered session: ${session_token:0:12}... (${display_name:-unknown})" + return 0 + else + log "ERROR: Session discovery returned invalid token" + return 1 + fi + fi + + # Local mode: check if stored session is still active via rails runner + if [[ -f "$SESSION_FILE" && -s "$SESSION_FILE" ]]; then + local existing + existing=$(cat "$SESSION_FILE") + local check_script + check_script="s = McpSession.find_by(session_token: \"$existing\"); if s&.active?; print [\"active\", s.display_name || s.ai_agent&.name || \"MCP\"].join(\"\t\"); elsif s&.reactivatable?; s.reactivate!; print [\"active\", s.display_name || s.ai_agent&.name || \"MCP\"].join(\"\t\"); else; print \"expired\"; end" + local result + result=$(cd "$SERVER_DIR" && bin/rails runner "$check_script" 2>>"$LOG_FILE") || true + if [[ "$result" == active* ]]; then + local session_name + IFS=$'\t' read -r _ session_name <<< "$result" + [[ -n "$session_name" ]] && echo -n "$session_name" > "$SESSION_NAME_FILE" + return 0 + fi + log "Session ${existing:0:12}... expired" + fi + + # Per-instance mode: actively discover session via HTTP (same as remote mode). + # Previously this was a passive wait for mcp-helper.sh to write the session file, + # but after removing the _mcp_create_session() fallback, nobody writes it during + # reconnection. HTTP discovery works regardless of local/remote mode. + if [[ -n "$INSTANCE_ID" ]]; then + log "Per-instance mode — discovering session via HTTP..." + + # Refresh token from source (CC credentials or rails runner), then read it. + # Don't rely on cached TOKEN_FILE — it may be stale after SSE disconnect. + refresh_token || true + local token + token=$(get_token) || { log "ERROR: No token for session discovery"; return 1; } + + local discover_payload='{"jsonrpc":"2.0","id":"discover-pi","method":"session/discover","params":{}}' + local discover_response + discover_response=$(curl -sS -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$discover_payload" \ + "$SSE_ENDPOINT" 2>>"$LOG_FILE") || { log "ERROR: session/discover request failed"; return 1; } + + local picked + picked=$(_pick_unclaimed_session "$discover_response" "${_PREFERRED_SESSION:-}") || { + log "No unclaimed sessions available — waiting for CLI to connect" + return 1 + } + + local session_token display_name discovered_agent_id + IFS=$'\t' read -r session_token display_name discovered_agent_id <<< "$picked" + + if [[ -n "$session_token" && ${#session_token} -gt 10 ]]; then + echo -n "$session_token" > "$SESSION_FILE" + [[ -n "$display_name" ]] && echo -n "$display_name" > "$SESSION_NAME_FILE" + if [[ -n "$discovered_agent_id" && "$discovered_agent_id" != "${AGENT_ID:-}" ]]; then + log "Agent ID set from discover: ${AGENT_ID:-none} -> $discovered_agent_id" + AGENT_ID="$discovered_agent_id" + fi + log "Discovered session: ${session_token:0:12}... (${display_name:-unknown})" + return 0 + else + log "ERROR: Session discovery returned invalid token" + return 1 + fi + fi + + # Shared mode fallback: find any active CLI session + local session_script + session_script=$(cat <>"$LOG_FILE") || true + + if [[ -n "$result" ]]; then + local session_token session_name synced_agent_id + IFS=$'\t' read -r session_token session_name synced_agent_id <<< "$result" + echo -n "$session_token" > "$SESSION_FILE" + echo -n "${session_name:-MCP}" > "$SESSION_NAME_FILE" + if [[ -n "$synced_agent_id" && "$synced_agent_id" != "${AGENT_ID:-}" ]]; then + log "Agent ID updated from session: ${AGENT_ID:-none} -> $synced_agent_id" + AGENT_ID="$synced_agent_id" + fi + log "Sharing CLI session: ${session_token:0:12}... (${session_name})" + return 0 + else + log "No active CLI session found — waiting for CLI to connect" + return 1 + fi +} + +# --- Parent Process Monitor --- +# In per-instance mode, the daemon should exit when its parent Claude process dies. +_check_parent_alive() { + [[ -z "$INSTANCE_ID" ]] && return 0 # Shared mode — no parent to check + kill -0 "$INSTANCE_ID" 2>/dev/null && return 0 + log "Parent process $INSTANCE_ID is dead — daemon shutting down" + return 1 +} + +# --- Deduplication --- +mark_seen() { + local msg_id="$1" + echo "$msg_id" >> "$SEEN_FILE" + # Keep file bounded + if [[ -f "$SEEN_FILE" ]]; then + local count + count=$(wc -l < "$SEEN_FILE") + if (( count > 500 )); then + tail -200 "$SEEN_FILE" > "${SEEN_FILE}.tmp" && mv "${SEEN_FILE}.tmp" "$SEEN_FILE" + fi + fi +} + +is_seen() { + local msg_id="$1" + [[ -n "$msg_id" && -f "$SEEN_FILE" ]] && grep -qF "$msg_id" "$SEEN_FILE" +} + +# --- Inbox Management --- +write_event() { + local json="$1" + echo "$json" >> "$INBOX_FILE" + if [[ -f "$INBOX_FILE" ]]; then + local count + count=$(wc -l < "$INBOX_FILE") + if (( count > MAX_INBOX_LINES )); then + tail -$((MAX_INBOX_LINES / 2)) "$INBOX_FILE" > "${INBOX_FILE}.tmp" && mv "${INBOX_FILE}.tmp" "$INBOX_FILE" + fi + fi +} + +# --- SSE Event Processor --- +# Uses python3 for robust JSON parsing — avoids shell string escaping nightmares. +# Reads the raw SSE data JSON and writes a normalized inbox entry. +process_sse_event() { + local event_type="$1" + local data="$2" + + # Skip non-workspace events + case "$event_type" in + ping|open) return ;; + message) return ;; # MCP JSON-RPC notifications + message_created|mention|ai_response_complete) ;; + *) log "Ignoring event type: $event_type"; return ;; + esac + + # Use python3 for all JSON operations — safe against any content. + # All workspace messages arrive via the workspace channel broadcast. The python + # processor detects if this agent is @mentioned (by ID or @Name in content) and + # promotes event_type to 'mention' for downstream nudge decisions. + local result + result=$(python3 -c " +import json, sys +from datetime import datetime, timezone + +raw = sys.argv[1] +event_type = sys.argv[2] +seen_file = sys.argv[3] +my_agent_id = sys.argv[4] if len(sys.argv) > 4 else '' + +try: + d = json.loads(raw) +except json.JSONDecodeError: + sys.exit(1) + +# The event may have a nested 'message' object (both broadcast formats do) +msg = d.get('message', {}) if isinstance(d.get('message'), dict) else {} + +# Extract message_id for dedup — try message.id first, then top-level +msg_id = str(msg.get('id', '') or d.get('message_id', '') or '') + +# Check dedup — but allow ai_response_complete to update existing entries +# (message_created fires with partial streaming content, ai_response_complete has the full text) +is_update = False +if msg_id: + try: + with open(seen_file, 'r') as f: + if msg_id in f.read(): + if event_type == 'ai_response_complete': + is_update = True # Fall through to build full entry with UPDATE: prefix + else: + print('DEDUP') + sys.exit(0) + except FileNotFoundError: + pass + +# Extract sender — multiple possible locations: +# - message.sender (MCP pubsub format: plain string) +# - message.sender_info.name (ActionCable format: object) +# - top-level sender_name (manual/test broadcasts) +sender = '' +if isinstance(msg.get('sender'), str) and msg['sender']: + sender = msg['sender'] +elif isinstance(msg.get('sender_info'), dict): + sender = msg['sender_info'].get('name', '') +if not sender: + sender = d.get('sender_name', '') or d.get('sender', '') or '' + if isinstance(sender, dict): + sender = sender.get('name', '') +sender = sender or 'Unknown' + +# Extract content — from message.content or top-level +content = str(msg.get('content', '') or d.get('content', '') or '') + +# Extract workspace name — top-level only (MCP pubsub format has it) +workspace = str(d.get('workspace', '') or d.get('workspace_name', '') or '') + +# Conversation ID — top-level or from d +conv_id = str(d.get('conversation_id', '') or '') + +# Detect @mentions: check structured metadata and text @Name pattern. +# If this agent is mentioned, promote event_type to 'mention' so the daemon +# knows to nudge (for idle wake-up) vs quiet delivery (for active work). +effective_event = event_type +if my_agent_id and event_type in ('message_created', 'ai_response_complete'): + metadata = msg.get('metadata', {}) or {} + mentions = metadata.get('mentions') or msg.get('content_metadata', {}).get('mentions') or [] + mentioned = False + if mentions: + mentioned_ids = [str(m.get('id', '')) for m in mentions if isinstance(m, dict)] + mentioned = my_agent_id in mentioned_ids + if not mentioned and my_agent_id and content: + # Text fallback: @AgentName pattern (agent name not available here, + # but mentioned_agent_id in top-level is set by server for direct mentions) + mentioned_id = str(d.get('mentioned_agent_id', '') or '') + mentioned = mentioned_id == my_agent_id + if mentioned: + effective_event = 'mention' + +entry = { + 'ts': datetime.now(timezone.utc).isoformat(), + 'event': effective_event, + 'workspace': workspace, + 'sender': sender, + 'content': content, + 'message_id': msg_id, + 'conversation_id': conv_id, + 'read': False +} + +prefix = 'UPDATE:' if is_update else '' +print(prefix + json.dumps(entry)) +" "$data" "$event_type" "$SEEN_FILE" "${AGENT_ID:-}" 2>>"$LOG_FILE") || return + + if [[ "$result" == "DEDUP" ]]; then + log "Dedup: skipping duplicate event" + return + fi + + if [[ "$result" == UPDATE:* ]]; then + # ai_response_complete with full content — replace the partial entry in inbox + local updated_json="${result#UPDATE:}" + local update_msg_id + update_msg_id=$(echo "$updated_json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('message_id',''))" 2>/dev/null) || true + + if [[ -n "$update_msg_id" && -f "$INBOX_FILE" ]]; then + # Replace the line containing this message_id with the updated entry + python3 -c " +import sys, json + +msg_id = sys.argv[1] +new_entry = sys.argv[2] +inbox = sys.argv[3] + +lines = [] +replaced = False +with open(inbox, 'r') as f: + for line in f: + line = line.rstrip('\n') + if not line: + continue + try: + entry = json.loads(line) + if entry.get('message_id') == msg_id: + lines.append(new_entry) + replaced = True + continue + except (json.JSONDecodeError, AttributeError): + pass + lines.append(line) + +if not replaced: + lines.append(new_entry) + +with open(inbox, 'w') as f: + f.write('\n'.join(lines) + '\n') +" "$update_msg_id" "$updated_json" "$INBOX_FILE" 2>>"$LOG_FILE" + log "UPDATE [$event_type] msg_id=$update_msg_id — replaced partial with complete content" + fi + return + fi + + if [[ -n "$result" ]]; then + write_event "$result" + # Extract fields via python (tab-delimited to preserve names with spaces) + # Includes effective_event which may be 'mention' even when SSE event_type is 'message_created' + local fields msg_id sender content effective_event + fields=$(echo "$result" | python3 -c " +import sys, json +e = json.load(sys.stdin) +print(e.get('message_id','') + '\t' + e.get('sender','?') + '\t' + e.get('content','')[:120] + '\t' + e.get('event','message_created')) +" 2>/dev/null) || fields="?\t?\t?\tmessage_created" + IFS=$'\t' read -r msg_id sender content effective_event <<< "$fields" + [[ -n "$msg_id" ]] && mark_seen "$msg_id" + log "EVENT [$effective_event] from $sender" + + # Desktop notification + if command -v notify-send &>/dev/null; then + local urgency="normal" + [[ "$effective_event" == "mention" ]] && urgency="critical" + notify-send -u "$urgency" -i dialog-information -a "Powernode" \ + "$sender" "$content" 2>/dev/null || true + fi + + # Inject into Claude Code via tmux — only for @mentions and slash commands. + # Regular messages are delivered quietly via the Stop hook (when Claude is + # active) or UserPromptSubmit (on next user input). The status line shows + # an unread count for passive awareness. + local should_nudge=false + if [[ "$sender" == *Claude\ Code* ]]; then + : # Never nudge for our own messages (avoid loops) + elif [[ "$effective_event" == "mention" ]]; then + should_nudge=true # @mentions still nudge (proactive for idle) + elif [[ "$content" == /* ]]; then + should_nudge=true # Slash commands need literal tmux injection + fi + # Regular messages no longer nudge — delivered via Stop hook when active, + # or via UserPromptSubmit when user next types. Status line shows unread count. + + if [[ "$should_nudge" == true ]]; then + nudge_claude "$content" + fi + fi +} + +# --- SSE Connection --- +run_sse_loop() { + local token session_id + token=$(get_token) || { log "ERROR: No token available"; return 1; } + session_id=$(cat "$SESSION_FILE" 2>/dev/null) || { log "ERROR: No session available"; return 1; } + + log "Connecting to SSE: $SSE_ENDPOINT (session: ${session_id:0:12}...)" + + # Start curl in background via process substitution, capture its PID. + # This allows _kill_orphan_curls to kill only THIS daemon's curl — not + # other instances' connections (which caused cascading disconnects). + exec 3< <(curl -sS -N \ + --max-time 0 \ + -H "Authorization: Bearer $token" \ + -H "Mcp-Session-Id: $session_id" \ + -H "Accept: text/event-stream" \ + -H "Cache-Control: no-cache" \ + "$SSE_ENDPOINT" 2>>"$LOG_FILE") + _CURL_PID=$! + + { + set +eo pipefail # Disable errexit — process_sse_event may fail non-fatally + local current_event="" current_data="" + + log "SSE read loop started (read timeout: ${SSE_READ_TIMEOUT}s, curl PID: $_CURL_PID)" + + while IFS= read -t "$SSE_READ_TIMEOUT" -r line; do + line="${line%$'\r'}" + + if [[ "$line" == event:* ]]; then + current_event="${line#event: }" + elif [[ "$line" == data:* ]]; then + if [[ -z "$current_data" ]]; then + current_data="${line#data: }" + else + current_data="${current_data}${line#data: }" + fi + elif [[ -z "$line" ]]; then + if [[ -n "$current_event" && -n "$current_data" ]]; then + process_sse_event "$current_event" "$current_data" || log "process_sse_event failed for $current_event" + fi + current_event="" + current_data="" + fi + done + + log "SSE read loop exited (read timeout or EOF)" + } <&3 + exec 3<&- + local exit_code=$? + + # Kill only THIS daemon's curl process — not other instances' connections. + _kill_orphan_curls + + log "SSE connection closed (exit: $exit_code) — will reconnect" + return 1 +} + +_kill_orphan_curls() { + # Kill only THIS daemon's curl process — not other instances' connections. + # Previously used pgrep -f which matched ALL curl SSE processes system-wide, + # causing cascading disconnects when one daemon's read loop timed out. + if [[ -n "$_CURL_PID" ]] && kill -0 "$_CURL_PID" 2>/dev/null; then + kill "$_CURL_PID" 2>/dev/null + sleep 0.5 + kill -9 "$_CURL_PID" 2>/dev/null || true + log "Killed own curl PID $_CURL_PID" + fi + _CURL_PID="" +} + +# --- Daemon Loop --- +_run_daemon_loop() { + trap '_cleanup' EXIT SIGTERM SIGINT + + # SIGHUP → reload: kill curl to break the SSE read loop, then reconnect + # immediately (skip backoff). Useful for picking up new channel subscriptions + # after a workspace team change without a full stop/start cycle. + _RELOAD_REQUESTED=0 + trap '_RELOAD_REQUESTED=1; _kill_orphan_curls' SIGHUP + + local _PREFERRED_SESSION="" + local _DIRECT_RETRY=0 + local backoff=1 + local last_token_refresh + last_token_refresh=$(date +%s) + + while true; do + # In per-instance mode, exit when parent Claude process dies + _check_parent_alive || break + + # If no token available, poll with backoff until credentials appear + if ! get_token >/dev/null 2>&1; then + log "No token available — waiting for credentials (backoff: ${backoff}s)..." + sleep "$backoff" + backoff=$(( backoff * 2 )) + (( backoff > MAX_BACKOFF )) && backoff=$MAX_BACKOFF + continue + fi + + # Periodic token refresh + local now + now=$(date +%s) + if (( now - last_token_refresh > TOKEN_REFRESH_INTERVAL )); then + log "Periodic token refresh..." + if refresh_token; then + last_token_refresh=$now + backoff=1 + fi + fi + + # Ensure session is valid + ensure_session || { sleep "$backoff"; continue; } + + # Run SSE connection (blocks until disconnect) + if run_sse_loop; then + backoff=1 + _DIRECT_RETRY=0 # Reset direct reconnect counter on success + else + # After SSE disconnect, refresh token immediately instead of waiting + # for the 30-minute periodic interval — the disconnect may be caused + # by an expired token. + log "SSE disconnected — refreshing token before reconnect..." + if refresh_token; then + last_token_refresh=$(date +%s) + backoff=1 # Fresh token — reconnect quickly + log "Token refreshed successfully after disconnect" + else + log "Token refresh failed — will retry on next iteration" + fi + + # If SIGHUP triggered this disconnect, skip backoff and reconnect immediately + if [[ "$_RELOAD_REQUESTED" -eq 1 ]]; then + _RELOAD_REQUESTED=0 + log "Reload requested — reconnecting immediately with fresh channels..." + backoff=1 + _DIRECT_RETRY=0 + continue + fi + + # Try direct reconnect with our known session first — the server will + # reactivate the revoked session automatically when it receives the SSE + # GET request. This avoids the TOCTOU race where multiple daemons all + # delete their session files and call session/discover simultaneously, + # potentially grabbing the same session. + _PREFERRED_SESSION=$(cat "$SESSION_FILE" 2>/dev/null) || true + _DIRECT_RETRY=$(( ${_DIRECT_RETRY:-0} + 1 )) + + if [[ -n "$_PREFERRED_SESSION" && $_DIRECT_RETRY -le 3 ]]; then + log "Direct reconnect attempt $_DIRECT_RETRY/3 with ${_PREFERRED_SESSION:0:12}..." + # Keep session file intact — run_sse_loop will use the existing session + sleep "$backoff" + backoff=$(( backoff * 2 )) + (( backoff > MAX_BACKOFF )) && backoff=$MAX_BACKOFF + continue + fi + + # Either no preferred session or 3 direct attempts failed — rediscover + _DIRECT_RETRY=0 + rm -f "$SESSION_FILE" + log "Cleared session for rediscovery, reconnecting in ${backoff}s..." + sleep "$backoff" + backoff=$(( backoff * 2 )) + (( backoff > MAX_BACKOFF )) && backoff=$MAX_BACKOFF + fi + done +} + +_cleanup() { + # Prevent re-entry from signal during cleanup + trap '' EXIT SIGTERM SIGINT + log "Daemon shutting down..." + rm -f "$PID_FILE" "$SESSION_NAME_FILE" "$SEEN_FILE" + # Kill orphan curl SSE connections + _kill_orphan_curls + # Kill all children + kill 0 2>/dev/null || true + log "Daemon stopped" +} + +# --- Daemon Control --- +start_daemon() { + # Cross-caller mutual exclusion — prevents concurrent bootstrap from + # mcp-sse-autostart, workspace-messages, or manual invocation. + # Uses fd 200 (distinct from fd 9 in mcp-sse-autostart.sh) but the SAME + # lock file path for cross-caller coordination. + local LOCK_FILE="/tmp/powernode_sse_daemon_${INSTANCE_ID:-shared}.lock" + exec 200>"$LOCK_FILE" + if ! flock -n 200; then + log_and_echo "Daemon startup already in progress (another caller holds lock)" + return 0 + fi + + if is_running; then + log_and_echo "Daemon already running (PID: $(cat "$PID_FILE"))" + exec 200>&- + return 0 + fi + + log_and_echo "Starting workspace SSE daemon..." + + # Clean stale session claim files — remove claims from dead PIDs, not just old files. + # This prevents a dead Claude Code instance from permanently "claiming" a session. + for f in /tmp/powernode_mcp_session_*.txt; do + [[ -f "$f" ]] || continue + [[ "$f" == *name* ]] && continue # skip name files + local claim_pid + claim_pid=$(basename "$f" | sed 's/powernode_mcp_session_//;s/\.txt//') + if [[ "$claim_pid" =~ ^[0-9]+$ ]] && ! kill -0 "$claim_pid" 2>/dev/null; then + rm -f "$f" "/tmp/powernode_mcp_session_name_${claim_pid}.txt" + log "Cleaned stale claim from dead PID $claim_pid" + fi + done + # Also clean very old files as safety net + find /tmp -maxdepth 1 -name 'powernode_mcp_session_*' -mmin +1440 -delete 2>/dev/null || true + find /tmp -maxdepth 1 -name 'powernode_mcp_session_name_*' -mmin +1440 -delete 2>/dev/null || true + + # Try to get a token — if unavailable, daemon launches anyway and polls for credentials + if [[ ! -f "$TOKEN_FILE" ]] || [[ ! -s "$TOKEN_FILE" ]]; then + log_and_echo "No token found, refreshing..." + refresh_token || log_and_echo "No token yet — daemon will poll for credentials" + fi + + # Try to discover session — non-blocking, daemon loop will retry with backoff + ensure_session || log_and_echo "No session yet — daemon will retry with backoff" + + # Launch daemon in background via setsid + nohup (propagate POWERNODE_URL for remote mode). + # setsid gives the daemon its own process group (PGID = daemon PID), so that + # _cleanup's `kill 0` and stop_daemon's `kill -- -PID` only affect the daemon + # and its children — NOT the parent Claude Code session. + INSTANCE_ID="$INSTANCE_ID" POWERNODE_URL="$PLATFORM_URL" setsid nohup "$0" _daemon >> "$LOG_FILE" 2>&1 & + local daemon_pid=$! + disown "$daemon_pid" 2>/dev/null || true + echo "$daemon_pid" > "$PID_FILE" + + # Release lock now that PID file is written — other callers will see is_running() = true + exec 200>&- + + log_and_echo "Daemon started (PID: $daemon_pid)" + log_and_echo " Inbox: $INBOX_FILE" + log_and_echo " Log: $LOG_FILE" + log_and_echo " Session: $(cat "$SESSION_FILE" 2>/dev/null | head -c 12)..." +} + +stop_daemon() { + if ! is_running; then + log_and_echo "Daemon is not running" + rm -f "$PID_FILE" + # No global pkill — other daemons' curls are their own business. + # The process group kill below handles this daemon's children. + return 0 + fi + + local pid + pid=$(cat "$PID_FILE") + log_and_echo "Stopping daemon (PID: $pid)..." + + # Kill the entire process group to catch curl child processes. + # The daemon runs with its own PGID (equal to its PID via setsid). + kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + + # Wait for clean shutdown (up to 5s) + local i=0 + while (( i < 10 )) && kill -0 "$pid" 2>/dev/null; do + sleep 0.5 + i=$((i + 1)) + done + + if kill -0 "$pid" 2>/dev/null; then + log_and_echo "Force-killing daemon..." + kill -9 -- -"$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true + fi + + rm -f "$PID_FILE" + log_and_echo "Daemon stopped" +} + +is_running() { + [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +reload_daemon() { + if ! is_running; then + log_and_echo "Daemon is not running — nothing to reload" + return 1 + fi + + local pid + pid=$(cat "$PID_FILE") + log_and_echo "Sending SIGHUP to daemon (PID: $pid)..." + kill -HUP "$pid" 2>/dev/null + log_and_echo "Reload signal sent — daemon will reconnect with fresh channels" +} + +show_status() { + if [[ -n "$INSTANCE_ID" ]]; then + _show_instance_status + else + _show_discovery_status + fi +} + +# Single-instance status — used when INSTANCE_ID is set (normal hook invocation) +_show_instance_status() { + if is_running; then + local pid + pid=$(cat "$PID_FILE") + echo "Workspace SSE daemon: RUNNING (PID: $pid)" + local inbox_count=0 + [[ -f "$INBOX_FILE" ]] && inbox_count=$(wc -l < "$INBOX_FILE") + echo " Inbox: $INBOX_FILE ($inbox_count events)" + echo " Log: $LOG_FILE" + echo " Token: $TOKEN_FILE ($(stat -c%s "$TOKEN_FILE" 2>/dev/null || echo 0) bytes)" + echo " Session: $(cat "$SESSION_FILE" 2>/dev/null || echo 'none')" + echo "" + local unread=0 + [[ -f "$INBOX_FILE" ]] && unread=$(grep -c '"read": false' "$INBOX_FILE" 2>/dev/null || true) + echo " Unread events: ${unread:-0}" + echo "" + echo " Last 3 log entries:" + tail -3 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' + else + echo "Workspace SSE daemon: STOPPED" + if [[ -f "$PID_FILE" ]]; then + echo " (stale PID file exists — daemon may have crashed)" + fi + fi +} + +# Discovery mode — scans all instances when invoked without INSTANCE_ID (manual CLI use) +_show_discovery_status() { + echo "=== Workspace SSE Daemon — Multi-Instance Status ===" + echo "" + + local found=0 + + # Scan all per-instance PID files + for pid_file in /tmp/powernode_sse_daemon_*.pid; do + [[ -f "$pid_file" ]] || continue + found=$((found + 1)) + + local instance_id daemon_pid + instance_id=$(basename "$pid_file" | sed 's/powernode_sse_daemon_//;s/\.pid//') + daemon_pid=$(cat "$pid_file" 2>/dev/null) + + local daemon_status="DEAD" + [[ -n "$daemon_pid" ]] && kill -0 "$daemon_pid" 2>/dev/null && daemon_status="RUNNING" + + local parent_status="DEAD" + [[ "$instance_id" =~ ^[0-9]+$ ]] && kill -0 "$instance_id" 2>/dev/null && parent_status="ALIVE" + + echo "Instance $instance_id:" + echo " Daemon: $daemon_status (PID: ${daemon_pid:-?})" + echo " Parent: $parent_status (Claude PID: $instance_id)" + + local inbox_file="/tmp/powernode_workspace_inbox_${instance_id}.jsonl" + local session_file="/tmp/powernode_mcp_session_${instance_id}.txt" + local log_file="/tmp/powernode_sse_daemon_${instance_id}.log" + + local inbox_count=0 unread=0 + if [[ -f "$inbox_file" ]]; then + inbox_count=$(wc -l < "$inbox_file") + unread=$(grep -c '"read": false' "$inbox_file" 2>/dev/null || true) + fi + echo " Inbox: $inbox_count events (${unread:-0} unread)" + echo " Session: $(cat "$session_file" 2>/dev/null | head -c 16 || echo 'none')..." + + if [[ -f "$log_file" ]]; then + echo " Last log: $(tail -1 "$log_file" 2>/dev/null | head -c 100)" + fi + + # Flag anomalies + if [[ "$daemon_status" == "RUNNING" && "$parent_status" == "DEAD" ]]; then + echo " ⚠ ORPHAN: daemon running but parent Claude is dead — will self-terminate" + fi + if [[ "$daemon_status" == "DEAD" && "$parent_status" == "ALIVE" ]]; then + echo " ⚠ MISSING: Claude is alive but has no daemon — restart with: INSTANCE_ID=$instance_id $0 start" + fi + + echo "" + done + + # Detect orphan daemons not tracked by any PID file + local tracked_pids="" + for pid_file in /tmp/powernode_sse_daemon_*.pid; do + [[ -f "$pid_file" ]] || continue + local p + p=$(cat "$pid_file" 2>/dev/null) + [[ -n "$p" ]] && tracked_pids="$tracked_pids $p" + done + + local orphan_count=0 + while IFS= read -r opid; do + [[ -z "$opid" ]] && continue + if [[ " $tracked_pids " != *" $opid "* ]]; then + if [[ "$orphan_count" -eq 0 ]]; then + echo "ORPHAN DAEMONS (not tracked by any PID file):" + fi + orphan_count=$((orphan_count + 1)) + echo " PID $opid — kill with: kill $opid" + fi + done < <(pgrep -f "workspace-sse-daemon.sh _daemon" 2>/dev/null || true) + [[ "$orphan_count" -gt 0 ]] && echo "" + + # Shared token file status + if [[ -f "$TOKEN_FILE" ]]; then + local token_age + token_age=$(( $(date +%s) - $(stat -c%Y "$TOKEN_FILE" 2>/dev/null) )) + echo "Shared token: $TOKEN_FILE ($(stat -c%s "$TOKEN_FILE" 2>/dev/null || echo 0) bytes, ${token_age}s old)" + else + echo "Shared token: NOT FOUND" + fi + + if [[ "$found" -eq 0 && "$orphan_count" -eq 0 ]]; then + echo "No daemon instances found." + fi +} + +tail_events() { + if [[ ! -f "$INBOX_FILE" ]]; then + echo "No events (inbox file does not exist)" + return + fi + + local count + count=$(wc -l < "$INBOX_FILE") + echo "Last 10 events (of $count total):" + echo "" + + tail -10 "$INBOX_FILE" | python3 -c " +import json, sys +for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + ts = e.get('ts', '?') + evt = e.get('event', '?') + sender = e.get('sender', '?') + content = e.get('content', '')[:60] + status = 'read' if e.get('read') else 'UNREAD' + print(f' [{ts}] ({evt}) {sender}: {content} [{status}]') + except json.JSONDecodeError: + print(f' [parse error] {line[:60]}') +" 2>/dev/null +} + +# --- Main --- +case "${1:-}" in + start) + start_daemon + ;; + stop) + stop_daemon + ;; + status) + show_status + ;; + tail) + tail_events + ;; + reload) + reload_daemon + ;; + refresh) + refresh_token && echo "Token refreshed" || echo "Token refresh failed" + ;; + _daemon) + # Internal: called by start_daemon via nohup + _run_daemon_loop + ;; + *) + echo "Usage: $0 {start|stop|reload|status|tail|refresh}" + exit 1 + ;; +esac diff --git a/.claude/hooks/workspace-stop-check.sh b/.claude/hooks/workspace-stop-check.sh new file mode 100755 index 000000000..88ea3700c --- /dev/null +++ b/.claude/hooks/workspace-stop-check.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Stop hook — checks workspace inbox after each Claude response. +# Outputs context if unread messages exist. + +exec 0/dev/null); do + if ps -p "$cpid" -o comm= 2>/dev/null | grep -q '^claude$'; then + CLAUDE_PID="$cpid" + break + fi +done + +# No Claude running in this pane — output nothing (tmux shows default) +[[ -z "$CLAUDE_PID" ]] && exit 0 + +# --- Per-instance daemon status --- +PID_FILE="/tmp/powernode_sse_daemon_${CLAUDE_PID}.pid" +SESSION_FILE="/tmp/powernode_mcp_session_${CLAUDE_PID}.txt" +NAME_FILE="/tmp/powernode_mcp_session_name_${CLAUDE_PID}.txt" + +NAME=$(cat "$NAME_FILE" 2>/dev/null) +NAME="${NAME:-MCP}" + +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then + if [[ -f "$SESSION_FILE" && -s "$SESSION_FILE" ]]; then + echo "${NAME}: LIVE" + else + echo "${NAME}: IDLE" + fi +else + echo "${NAME}: DOWN" +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..855df515a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,92 @@ +{ + "env": { + "POWERNODE_ROOT": "/opt/powernode", + "POWERNODE_URL": "https://platform.powernode.org" + }, + "mcpServers": { + "filesystem": { + "command": "mcp-server-filesystem", + "args": ["/opt/powernode"] + }, + "sequential-thinking": { + "command": "mcp-server-sequential-thinking", + "args": [] + }, + "powernode": { + "type": "streamable-http", + "url": "https://platform.powernode.org/api/v1/mcp/message" + } + }, + "statusLine": { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/statusline.sh" + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/mcp-sse-autoconnect.sh", + "timeout": 3 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workspace-messages.sh", + "timeout": 3 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workspace-stop-check.sh", + "timeout": 3 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "mcp__powernode__", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/mcp-sse-autostart.sh", + "timeout": 5 + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ruby-syntax-check.sh" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/console-log-check.sh" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/controller-size-check.sh" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ruby-convention-check.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md new file mode 100644 index 000000000..c4b863d14 --- /dev/null +++ b/.claude/skills/audit/SKILL.md @@ -0,0 +1,82 @@ +--- +name: audit +description: Run comprehensive codebase quality and pattern compliance audit +disable-model-invocation: true +allowed-tools: Bash(./scripts/*), Bash(cd *), Bash(npx *), Bash(bundle *), Bash(git *), Read, Grep, Glob +argument-hint: [focus: all|backend|frontend|types|patterns] +--- + +# Codebase Quality Audit + +Run quality checks and report results. Accept an optional focus argument to limit scope. + +## Focus Areas + +- **all** (default) — run everything +- **backend** — Ruby checks only (steps 1, 4, 5) +- **frontend** — Frontend checks only (steps 3, 6, 7) +- **types** — TypeScript type check only (step 3) +- **patterns** — Pattern validation only (steps 1, 2) + +## Checks + +Run applicable checks sequentially. Capture output from each. + +### 1. Pattern Validation +```bash +cd $PROJECT_DIR && ./scripts/pattern-validation.sh +``` + +### 2. Quick Pattern Check +```bash +cd $PROJECT_DIR && ./scripts/quick-pattern-check.sh +``` + +### 3. TypeScript Type Check +```bash +cd $PROJECT_DIR/frontend && npx tsc --noEmit 2>&1 +``` + +### 4. Ruby Syntax Check +Check all `.rb` files changed since last commit: +```bash +cd $PROJECT_DIR/server && git diff --name-only HEAD -- '*.rb' | xargs -I{} ruby -c {} 2>&1 +``` +Also check untracked `.rb` files: +```bash +cd $PROJECT_DIR/server && git ls-files --others --exclude-standard -- '*.rb' | xargs -I{} ruby -c {} 2>&1 +``` + +### 5. Frozen String Literal Pragma +Scan for Ruby files missing the pragma: +```bash +grep -rL "frozen_string_literal: true" $PROJECT_DIR/server/app/ --include="*.rb" | head -20 +``` + +### 6. Console.log Scan +```bash +grep -rn "console\.log" $PROJECT_DIR/frontend/src/ --include="*.ts" --include="*.tsx" | head -20 +``` + +### 7. Hardcoded Color Scan +```bash +grep -rn "bg-\(red\|blue\|green\|yellow\|gray\|slate\|zinc\|neutral\|stone\)" $PROJECT_DIR/frontend/src/ --include="*.tsx" | grep -v "theme" | head -20 +``` + +## Output + +Present results as a summary table: + +``` +| Check | Status | Issues | +|------------------------|--------|--------| +| Pattern Validation | ✅/❌ | count | +| Quick Pattern Check | ✅/❌ | count | +| TypeScript Types | ✅/❌ | count | +| Ruby Syntax | ✅/❌ | count | +| Frozen String Literal | ✅/❌ | count | +| Console.log | ✅/❌ | count | +| Hardcoded Colors | ✅/❌ | count | +``` + +If any check has issues, list the specific files/errors below the table. diff --git a/.claude/skills/bootstrap/SKILL.md b/.claude/skills/bootstrap/SKILL.md new file mode 100644 index 000000000..453e4fd11 --- /dev/null +++ b/.claude/skills/bootstrap/SKILL.md @@ -0,0 +1,72 @@ +--- +name: bootstrap +description: Bootstrap dev environment - migrations, seeds, services, smoke tests +disable-model-invocation: true +--- + +# Bootstrap Dev Environment + +Validate and fix the development environment end-to-end. Follow this process exactly: + +## Step 1: Check Pending Migrations + +```bash +cd server && bundle exec rails db:migrate:status +``` + +If any migrations are **down**, run `bundle exec rails db:migrate`. + +## Step 2: Audit Seed Files + +Use Grep and Read to scan `server/db/seeds/` files for: +- `class_name:` references that don't match actual models in `server/app/models/` +- Missing `foreign_key:` paired with `class_name:` +- Hardcoded UUIDs that may conflict + +Report any issues found before proceeding. + +## Step 3: Run Seeds + +```bash +cd server && bundle exec rails db:seed 2>&1 +``` + +If seeds fail: +1. Read the error message carefully +2. Identify the failing seed file and line +3. Read the seed file and the related model +4. Fix the seed file (association name, missing record, validation error) +5. Re-run `bundle exec rails db:seed` +6. **Max 3 attempts** — if still failing, stop and report the error + +## Step 4: Restart Services + +```bash +sudo systemctl restart powernode-backend@default +sudo systemctl restart powernode-worker@default +``` + +Wait 5 seconds, then check status: + +```bash +sudo scripts/systemd/powernode-installer.sh status +``` + +## Step 5: Smoke Test + +Run these health checks: + +```bash +curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/v1/health +curl -s -o /dev/null -w "%{http_code}" http://localhost:4567/ +``` + +Expected: `200` for both. Report any failures. + +## Step 6: Summary + +Report: +- Migrations applied (if any) +- Seed issues found and fixed (if any) +- Service status +- Smoke test results diff --git a/.claude/skills/cleanup/SKILL.md b/.claude/skills/cleanup/SKILL.md new file mode 100644 index 000000000..25c9672f9 --- /dev/null +++ b/.claude/skills/cleanup/SKILL.md @@ -0,0 +1,49 @@ +# /cleanup — Codebase Cleanup + +Run codebase cleanup and validation scripts. Wraps existing automation scripts into a single command. + +## Usage + +``` +/cleanup # Run all cleanup checks +/cleanup console # Remove console.log statements +/cleanup patterns # Run pattern validation audit +/cleanup colors # Fix hardcoded color violations +/cleanup imports # Convert relative imports to path aliases +/cleanup quick # Quick pattern check only +``` + +## Workflow + +### Determine Scope + +Based on the argument (default: `all`), run the appropriate scripts: + +| Argument | Script | Description | +|----------|--------|-------------| +| `all` | All scripts below in order | Full cleanup pass | +| `console` | `./scripts/cleanup-all-console-logs.sh` | Remove `console.log` from frontend | +| `patterns` | `./scripts/pattern-validation.sh` | Full pattern audit (colors, imports, types) | +| `colors` | `./scripts/fix-hardcoded-colors.sh` | Replace hardcoded colors with theme classes | +| `imports` | `./scripts/convert-relative-imports.sh` | Convert relative imports to `@/` aliases | +| `quick` | `./scripts/quick-pattern-check.sh` | Fast pattern check | + +### Execution + +1. Run the selected script(s) from the project root +2. Capture output and report results +3. If `all` mode, run in this order: + 1. `./scripts/cleanup-all-console-logs.sh` + 2. `./scripts/fix-hardcoded-colors.sh` + 3. `./scripts/convert-relative-imports.sh` + 4. `./scripts/pattern-validation.sh` +4. Report summary of changes made and any remaining issues + +### Post-Cleanup Verification + +After cleanup, run quality gates: +```bash +cd frontend && npx tsc --noEmit # Verify no TypeScript errors introduced +``` + +If TypeScript errors are introduced by cleanup, fix them before reporting completion. diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 000000000..33a17c502 --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,67 @@ +--- +name: commit +description: Create staged logical commits grouped by concern +disable-model-invocation: true +argument-hint: [optional scope or message hint] +--- + +# Staged Commit Workflow + +Create logical, grouped commits from all current changes. Follow this process exactly: + +## Step 1: Analyze Changes + +Run these commands in parallel: +- `git status` — see all changed/untracked files in the parent repo +- `git diff --stat` — see change summary for tracked files +- `git diff --cached --stat` — see already-staged changes +- `git -C extensions/business status --short` — see changes inside the business submodule +- `git log --oneline -5` — see recent commit style + +## Step 2: Group Files by Concern + +Organize changed files into groups (skip empty groups): +1. **Migrations** — `db/migrate/` +2. **Models** — `app/models/` +3. **Services** — `app/services/` +4. **Controllers & Routes** — `app/controllers/`, `config/routes.rb` +5. **Frontend** — `frontend/src/` +6. **Tests** — `spec/`, `e2e/`, `__tests__/` +7. **Seeds & Config** — `db/seeds/`, `config/`, `.claude/`, `scripts/` +8. **Documentation** — `docs/`, `*.md` (only if explicitly changed) + +When changes span both repos, group business changes separately from core. + +## Step 3: Create Commits + +### Business submodule (commit FIRST if it has changes) + +If `git -C extensions/business status` shows changes: +1. Group business changes by concern (backend vs frontend) +2. Stage with `git -C extensions/business add ` +3. Commit with `git -C extensions/business commit -m "type(scope): description"` +4. After all business commits, update the submodule pointer in the parent: `git add extensions/business` + +### Parent repo + +For each non-empty group: +1. `git add ` — NEVER use `git add -A` or `git add .` +2. Commit with conventional format: `type(scope): description` + - Types: `feat`, `fix`, `refactor`, `test`, `chore`, `docs` + - Scope: `backend`, `frontend`, `worker`, `config`, `db`, `business` + - Description: concise, lowercase, no period + +If the business submodule pointer changed (from business commits above), include it in the appropriate parent commit or as a separate `chore(business): update submodule pointer` commit. + +**Rules:** +- **NO** Claude attribution (no Co-Authored-By, no "Generated with") +- **NO** `git add -A` or `git add .` +- If a hint/scope argument was provided, use it to guide the commit messages + +## Step 4: Summary + +Run these in parallel: +- `git log --oneline -10` — show parent repo commits +- `git -C extensions/business log --oneline -5` — show business commits + +Show all commits created in both repos. diff --git a/.claude/skills/fix-tests/SKILL.md b/.claude/skills/fix-tests/SKILL.md new file mode 100644 index 000000000..4cc2362b3 --- /dev/null +++ b/.claude/skills/fix-tests/SKILL.md @@ -0,0 +1,59 @@ +--- +name: fix-tests +description: Run tests, diagnose failures, fix and re-run until green +disable-model-invocation: true +argument-hint: [scope: backend|frontend|e2e|path/to/spec] +--- + +# Fix Tests Workflow + +Run the test suite for the given scope, diagnose failures, fix them, and re-run. Follow this process exactly: + +## Step 1: Determine Scope + +Parse the argument to determine what to run: +- `backend` or no argument → `cd server && bundle exec rspec --format progress` +- `frontend` → `cd frontend && CI=true npm test` +- `e2e` → `cd frontend && npx playwright test` +- A specific file path → run that file directly + +## Step 2: Run Tests + +Execute the test command and capture output. Note all failures. + +## Step 3: Fix Loop (max 3 attempts per failure) + +For each failing test: + +1. **Read** the failing spec file and the source file it tests +2. **Diagnose** the root cause — check for: + - Missing factory traits (check `spec/factories/` and `spec/factories/ai/`) + - Wrong test helpers (use `user_with_permissions`, `auth_headers_for`, `json_response`) + - Missing shared examples (`spec/support/shared_examples/`) + - Missing mocks or stubs for external services + - Stale selectors in E2E tests (prefer `data-testid`) + - Import path issues (use `@/shared/`, `@/features/`) +3. **Fix** the issue using Edit +4. **Re-run** just the failing file to verify the fix +5. If still failing after 3 attempts on the same test, **stop and report** — do not keep iterating + +## Step 4: TypeScript Check (frontend/e2e only) + +If any TypeScript files were modified: + +```bash +cd frontend && npx tsc --noEmit +``` + +Fix any type errors found (same 3-attempt limit). + +## Step 5: Final Run + +Re-run the full suite for the original scope to confirm everything passes. + +## Step 6: Summary + +Report: +- Total tests: pass / fail / pending +- Failures fixed (list each with one-line description of what was wrong) +- Failures remaining (if any, with diagnosis of why they couldn't be auto-fixed) diff --git a/.claude/skills/gen-tests/SKILL.md b/.claude/skills/gen-tests/SKILL.md new file mode 100644 index 000000000..fbdd687dd --- /dev/null +++ b/.claude/skills/gen-tests/SKILL.md @@ -0,0 +1,147 @@ +# /gen-tests — Generate Missing Test Specs + +Generate RSpec request specs for untested controllers and service specs for untested services. + +## Usage + +``` +/gen-tests # List untested files, prompt for selection +/gen-tests controller # Generate specs for untested controllers +/gen-tests service # Generate specs for untested services +/gen-tests path/to/file.rb # Generate spec for a specific file +``` + +## Workflow + +### Step 1: Identify Untested Files + +Find controllers/services without corresponding specs: + +```bash +# Untested controllers +cd server && for f in $(find app/controllers/api/v1 -name '*_controller.rb'); do + spec="spec/requests/api/v1/$(basename "$f" .rb)_spec.rb" + [[ ! -f "$spec" ]] && echo "UNTESTED: $f" +done + +# Untested services +cd server && for f in $(find app/services -name '*.rb' | grep -v concerns); do + spec="spec/services/$(echo "$f" | sed 's|app/services/||; s|\.rb$|_spec.rb|')" + [[ ! -f "$spec" ]] && echo "UNTESTED: $f" +done +``` + +### Step 2: Read Source & Context + +For each file to test, read: +1. The source file itself (understand methods, params, permissions) +2. The routes file (`config/routes.rb`) for endpoint paths +3. Existing factories in `spec/factories/` relevant to the models used +4. Similar existing specs for pattern reference + +### Step 2b: Query MCP for Test Patterns (if available) + +Before generating specs, query MCP for relevant learnings that may improve spec quality: + +1. `platform.query_learnings` — query: `" test"` (e.g., `"ai agent test"`, `"subscription service test"`) +2. `platform.query_learnings` — query: `"factory "` (e.g., `"factory ai_agent"`, `"factory account"`) + +Apply any relevant findings (required factory traits, tricky setup, known assertion patterns) to the spec generation approach. + +**Graceful degradation**: If MCP tools are unavailable or return errors, skip this step and proceed with standard spec generation. MCP is additive, not required. + +### Step 3: Generate Spec + +Use these mandatory patterns from the project: + +**Request specs (controllers):** +```ruby +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe "Api::V1::ResourceName", type: :request do + include_examples 'requires authentication' + + let(:user) { user_with_permissions('resource.read', 'resource.manage') } + let(:headers) { auth_headers_for(user) } + let(:account) { user.account } + + describe "GET /api/v1/resources" do + it "returns resources for current account" do + resource = create(:resource, account: account) + get "/api/v1/resources", headers: headers + expect_success_response(json_response_data) + end + end + + describe "POST /api/v1/resources" do + let(:valid_params) { { resource: { name: "Test" } } } + + it "creates a resource" do + post "/api/v1/resources", params: valid_params, headers: headers + expect_success_response(json_response_data) + end + end +end +``` + +**Service specs:** +```ruby +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ServiceName, type: :service do + let(:account) { create(:account) } + let(:user) { user_with_permissions('relevant.permission') } + + describe '#method_name' do + it 'does the expected behavior' do + result = described_class.new(account).method_name + expect(result).to be_present + end + end +end +``` + +**Key helpers to use:** +- `user_with_permissions('perm.name')` — creates user with permissions +- `auth_headers_for(user)` — returns auth headers +- `json_response` / `json_response_data` — parse response body +- `expect_success_response(data)` / `expect_error_response(msg, status)` — response assertions +- `include_examples 'requires authentication'` — shared auth check +- `include_examples 'requires permission'` — shared permission check +- AI specs: use factories from `spec/factories/ai/` and helpers from `spec/support/ai_test_helpers.rb` + +### Step 4: Run & Fix + +```bash +cd server && bundle exec rspec spec/path/to/new_spec.rb --format progress +``` + +If tests fail, fix up to 3 times. After 3 failures, stop and report what needs manual attention. + +### Step 5: Report + +Output a summary: +- Files tested +- Specs generated (with paths) +- Pass/fail status +- Any specs that need manual attention + +### Step 5b: Contribute Learning (conditional) + +If spec generation revealed a **non-obvious pattern** worth capturing for future test generation, create a learning: + +``` +platform.create_learning( + title: "Test pattern: ", + content: "", + category: "pattern" +) +``` + +**Create a learning when**: required factory setup was non-trivial, mocking/stubbing required unusual configuration, assertions needed specific structure due to serializer behavior, or account-scoping had edge cases. + +**Skip for**: straightforward CRUD controller specs, standard factory usage, obvious permission checks. diff --git a/.claude/skills/powernode/SKILL.md b/.claude/skills/powernode/SKILL.md new file mode 100644 index 000000000..e380f55f7 --- /dev/null +++ b/.claude/skills/powernode/SKILL.md @@ -0,0 +1,41 @@ +# Powernode Platform Operations + +Interact with the Powernode knowledge ecosystem and skill management via MCP tools. + +## Routing + +Based on the user's request, determine the domain and use the appropriate MCP tool: + +### Knowledge Graph +- **Search**: `platform.search_knowledge_graph` (action=search, query, mode=hybrid/vector/keyword/graph) +- **Reason**: `platform.reason_knowledge_graph` (action=reason, query, max_hops) +- **Explore**: `platform.get_graph_node`, `platform.list_graph_nodes`, `platform.get_graph_neighbors` +- **Extract**: `platform.extract_to_knowledge_graph` (action=extract, text — runs LLM extraction pipeline to create nodes/edges) +- **Stats**: `platform.graph_statistics` + +### Shared Knowledge +- **Search**: `platform.search_knowledge` (action=search_knowledge, query) +- **Create**: `platform.create_knowledge` (action=create_knowledge, title, content, content_type, tags) +- **Update**: `platform.update_knowledge` (action=update_knowledge, entry_id, content, tags) +- **Promote**: `platform.promote_knowledge` (action=promote_knowledge, entry_id) + +### Compound Learnings +- **Query**: `platform.query_learnings` (action=query_learnings, query, category, scope) +- **Create**: `platform.create_learning` (action=create_learning, title, content, category) +- **Reinforce**: `platform.reinforce_learning` (action=reinforce_learning, learning_id) +- **Metrics**: `platform.learning_metrics` + +### Skills +- **List/Search**: `platform.list_skills` (action=list_skills, search, category, status) +- **Get**: `platform.get_skill` (action=get_skill, skill_id) +- **Discover**: `platform.discover_skills` (action=discover_skills, task_context) +- **Create**: `platform.create_skill` (action=create_skill, name, description, category, system_prompt, commands, tags) +- **Update**: `platform.update_skill` (action=update_skill, skill_id, + fields to change) +- **Toggle**: `platform.toggle_skill` (action=toggle_skill, skill_id, enabled) +- **Delete**: `platform.delete_skill` (action=delete_skill, skill_id) — cannot delete system skills +- **Health**: `platform.skill_health`, `platform.skill_metrics` + +## Guidelines +- Always search before creating to avoid duplicates (services auto-dedup at >=0.92 similarity but checking first is faster) +- Confirm destructive operations (delete, disable) with the user before executing +- Use categories consistently: pattern, anti_pattern, best_practice, discovery, fact, failure_mode diff --git a/.claude/skills/workspace/SKILL.md b/.claude/skills/workspace/SKILL.md new file mode 100644 index 000000000..4b434573c --- /dev/null +++ b/.claude/skills/workspace/SKILL.md @@ -0,0 +1,95 @@ +# Workspace Chat Operations + +Manage Powernode workspace conversations — read, send, create, and administer multi-agent chat workspaces. + +## Routing + +Determine the intent from the user's message (or the automated daemon trigger) and execute the appropriate operation. + +### Incoming Messages (default — no arguments) + +When invoked with no arguments or by the SSE daemon: + +1. Read the `` context injected by the `UserPromptSubmit` hook +2. For each message requiring a response, reply via the `platform.send_message` MCP tool with `conversation_id` and `message` params +3. Always acknowledge — never silently ignore workspace communications +4. Process messages in chronological order +5. After responding, continue with any current work in progress + +### Send a Message + +When the user wants to send a message to a workspace: + +| Tool | Parameters | +|------|-----------| +| `platform.send_message` | `conversation_id` (required), `message` (required) | + +- Include `@AgentName` in message text to mention and notify specific agents +- If the user doesn't specify a conversation, use the active conversation from `` context or ask + +### List Workspaces + +| Tool | Parameters | +|------|-----------| +| `platform.list_workspaces` | optional `limit` (default 10) | + +Shows workspace conversations the current user participates in. + +### Read Message History + +| Tool | Parameters | +|------|-----------| +| `platform.list_messages` | `conversation_id`, optional `limit` (default 20, max 100) | + +Retrieve recent messages from a workspace conversation. + +### Create a Workspace + +| Tool | Parameters | +|------|-----------| +| `platform.create_workspace` | `name` (required), optional `agent_ids`, optional `include_concierge` | + +Creates a new workspace conversation. The calling MCP client agent is automatically added. + +### Invite an Agent + +| Tool | Parameters | +|------|-----------| +| `platform.invite_agent` | `conversation_id`, `agent_id` (or `"concierge"` for default concierge) | + +Add an agent to an existing workspace conversation. + +### Active MCP Sessions + +| Tool | Parameters | +|------|-----------| +| `platform.active_sessions` | none | + +List active MCP client sessions that can be invited to workspaces. + +### Concierge Operations + +| Tool | Parameters | +|------|-----------| +| `platform.send_concierge_message` | `message` — sends to concierge, gets AI response | +| `platform.confirm_concierge_action` | `conversation_id`, `action_type`, optional `action_params` | +| `platform.list_conversations` | optional `status`, `limit` — list user's conversations | +| `platform.get_conversation_messages` | `conversation_id`, optional `limit` — get full history | + +## Workspace Slash Commands + +When a workspace message contains a `/command`, treat it as a **literal Claude Code slash command**. Execute it directly — do not interpret or reinterpret the intent. Pass it through exactly as received. + +Examples: `/clear`, `/commit`, `/workspace`, `/powernode`, `/audit`, etc. + +Acknowledge the command in the workspace after executing it. + +## Response Rules + +- **Always reply via `platform.send_message` MCP tool** — never respond in the CLI terminal for workspace communications +- **Mention agents** with `@AgentName` when directing responses to specific team members +- **Questions**: answer directly. **Task requests**: execute and report back with results +- **Errors**: if sending fails, inform the workspace with the error details +- **Context**: when workspace messages reference ongoing CLI work, bridge the context — summarize what you're doing or share results +- **Acknowledgments**: don't reply to simple "thanks" or "great" messages — break the courtesy loop +- **MCP tools still used for**: list_workspaces, list_messages, create_workspace, invite_agent, active_sessions, concierge operations diff --git a/.claude/statusline.sh b/.claude/statusline.sh new file mode 100755 index 000000000..98149e5b0 --- /dev/null +++ b/.claude/statusline.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Powernode MCP status line for Claude Code +# Displays: [Model] powernode: STATUS | ctx% | $cost + +set -euo pipefail + +PID_FILE="/tmp/powernode_sse_daemon_${PPID}.pid" +# Per-instance session (keyed by Claude Code PID), fallback to daemon's shared session +INSTANCE_SESSION="/tmp/powernode_mcp_session_${PPID}.txt" +if [[ -f "$INSTANCE_SESSION" && -s "$INSTANCE_SESSION" ]]; then + SESSION_FILE="$INSTANCE_SESSION" +else + SESSION_FILE="/tmp/powernode_sse_session.txt" +fi + +# Parse session JSON from stdin in one jq call +read -r MODEL CTX COST < <( + jq -r '[ + (.model.display_name // "Unknown"), + (.context_window.used_percentage // 0 | floor), + (.cost.total_cost_usd // 0) + ] | @tsv' 2>/dev/null || echo "Unknown 0 0" +) + +# Determine MCP daemon status +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then + if [[ -s "$SESSION_FILE" ]]; then + STATUS="\033[32mLIVE\033[0m" # green + else + STATUS="\033[33mIDLE\033[0m" # yellow + fi +else + STATUS="\033[31mDOWN\033[0m" # red +fi + +# Context color: green <70%, yellow 70-89%, red 90+% +if (( CTX >= 90 )); then + CTX_COLOR="\033[31m" # red +elif (( CTX >= 70 )); then + CTX_COLOR="\033[33m" # yellow +else + CTX_COLOR="\033[32m" # green +fi + +OUTPUT=$(printf "[%s] powernode: %b | %b%d%%\033[0m ctx | $%s" \ + "$MODEL" "$STATUS" "$CTX_COLOR" "$CTX" "$COST") + +PLAIN=$(printf "[%s] powernode: %s | %d%% ctx | $%s" "$MODEL" \ + "$(echo -e "$STATUS" | sed 's/\x1b\[[0-9;]*m//g')" "$CTX" "$COST") + +# Unread workspace messages (read-state architecture: inbox is read-only, +# seen IDs tracked in a separate file) +INBOX="/tmp/powernode_workspace_inbox_${PPID}.jsonl" +READ_STATE="/tmp/powernode_workspace_read_${PPID}.ids" +UNREAD=0 +if [[ -f "$INBOX" ]]; then + if [[ -f "$READ_STATE" ]]; then + # Count inbox message IDs not present in read-state file + TOTAL=$(grep -co '"message_id": *"[^"]*"' "$INBOX" 2>/dev/null || echo 0) + READ=$(wc -l < "$READ_STATE" 2>/dev/null || echo 0) + UNREAD=$(( TOTAL > READ ? TOTAL - READ : 0 )) + else + # No read-state file — all inbox messages are unread + UNREAD=$(grep -c '"message_id"' "$INBOX" 2>/dev/null || true) + fi +fi +if (( UNREAD > 0 )); then + OUTPUT="${OUTPUT} | \033[33m${UNREAD} unread\033[0m" + PLAIN="${PLAIN} | ${UNREAD} unread" +fi + +# Display in Claude Code statusline +printf '%s' "$OUTPUT" + +# Also write to per-instance temp file for tmux status bar (strip ANSI for tmux plain text). +# Uses $PPID (Claude Code's PID) so multiple sessions don't overwrite each other. +TMUX_FILE="/tmp/claude-status-tmux-${PPID}" +printf '%s' "$PLAIN" > "$TMUX_FILE" diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 000000000..3d277c5b7 --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,117 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "type-enum": [ + 2, + "always", + [ + "feat", + "fix", + "docs", + "style", + "refactor", + "test", + "chore", + "ci", + "perf", + "revert" + ] + ], + "type-case": [2, "always", "lower-case"], + "type-empty": [2, "never"], + "scope-case": [2, "always", "lower-case"], + "subject-case": [2, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"]], + "subject-empty": [2, "never"], + "subject-full-stop": [2, "never", "."], + "subject-max-length": [2, "always", 50], + "body-leading-blank": [1, "always"], + "footer-leading-blank": [1, "always"], + "header-max-length": [2, "always", 72] + }, + "prompt": { + "questions": { + "type": { + "description": "Select the type of change that you're committing", + "enum": { + "feat": { + "description": "A new feature", + "title": "Features", + "emoji": "✨" + }, + "fix": { + "description": "A bug fix", + "title": "Bug Fixes", + "emoji": "🐛" + }, + "docs": { + "description": "Documentation only changes", + "title": "Documentation", + "emoji": "📚" + }, + "style": { + "description": "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)", + "title": "Styles", + "emoji": "💎" + }, + "refactor": { + "description": "A code change that neither fixes a bug nor adds a feature", + "title": "Code Refactoring", + "emoji": "📦" + }, + "perf": { + "description": "A code change that improves performance", + "title": "Performance Improvements", + "emoji": "🚀" + }, + "test": { + "description": "Adding missing tests or correcting existing tests", + "title": "Tests", + "emoji": "🚨" + }, + "chore": { + "description": "Other changes that don't modify src or test files", + "title": "Chores", + "emoji": "♻️" + }, + "ci": { + "description": "Changes to our CI configuration files and scripts", + "title": "Continuous Integrations", + "emoji": "⚙️" + }, + "revert": { + "description": "Reverts a previous commit", + "title": "Reverts", + "emoji": "🗑" + } + } + }, + "scope": { + "description": "What is the scope of this change (e.g. component or file name)" + }, + "subject": { + "description": "Write a short, imperative tense description of the change" + }, + "body": { + "description": "Provide a longer description of the change" + }, + "isBreaking": { + "description": "Are there any breaking changes?" + }, + "breakingBody": { + "description": "A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself" + }, + "breaking": { + "description": "Describe the breaking changes" + }, + "isIssueAffected": { + "description": "Does this change affect any open issues?" + }, + "issuesBody": { + "description": "If issues are closed, the commit requires a body. Please enter a longer description of the commit itself" + }, + "issues": { + "description": "Add issue references (e.g. \"fix #123\", \"re #123\".)" + } + } + } +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..18b9f947c --- /dev/null +++ b/.env.example @@ -0,0 +1,78 @@ +# Powernode Platform Environment Configuration +# Copy this file to .env and fill in the values + +# ============================================================================= +# DATABASE +# ============================================================================= +POSTGRES_USER=powernode +POSTGRES_PASSWORD=your_secure_password_here +POSTGRES_DB=powernode_production + +# ============================================================================= +# REDIS +# ============================================================================= +REDIS_PASSWORD=your_redis_password_here + +# ============================================================================= +# APPLICATION SECRETS +# ============================================================================= +# Generate with: openssl rand -hex 64 +SECRET_KEY_BASE=your_secret_key_base_here +JWT_SECRET=your_jwt_secret_here +WORKER_API_KEY=your_worker_api_key_here + +# ============================================================================= +# DOMAIN & SSL +# ============================================================================= +DOMAIN=example.com +ACME_EMAIL=admin@example.com + +# Traefik dashboard auth (generate with: htpasswd -nb admin password) +TRAEFIK_AUTH=admin:$$apr1$$xyz... + +# ============================================================================= +# PAYMENT PROVIDERS +# ============================================================================= +# Stripe +STRIPE_API_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PUBLISHABLE_KEY=pk_live_... + +# PayPal +PAYPAL_CLIENT_ID=your_paypal_client_id +PAYPAL_CLIENT_SECRET=your_paypal_client_secret +PAYPAL_MODE=live + +# ============================================================================= +# EMAIL (Optional) +# ============================================================================= +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your_smtp_user +SMTP_PASSWORD=your_smtp_password +SMTP_FROM=noreply@example.com + +# ============================================================================= +# MONITORING (Optional) +# ============================================================================= +SENTRY_DSN=https://...@sentry.io/... +NEW_RELIC_LICENSE_KEY=your_license_key + +# ============================================================================= +# CLOUD STORAGE (Optional) +# ============================================================================= +# AWS S3 +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +AWS_REGION=us-east-1 +AWS_BUCKET=powernode-uploads + +# Google Cloud Storage +GCS_PROJECT_ID=your_project_id +GCS_BUCKET=powernode-uploads +GCS_CREDENTIALS=path/to/credentials.json + +# Azure Blob Storage +AZURE_STORAGE_ACCOUNT=your_account +AZURE_STORAGE_ACCESS_KEY=your_key +AZURE_STORAGE_CONTAINER=powernode-uploads diff --git a/.env.mcp.example b/.env.mcp.example new file mode 100644 index 000000000..1d190f68e --- /dev/null +++ b/.env.mcp.example @@ -0,0 +1,56 @@ +# MCP Server Credentials +# Copy this file to .env.mcp and fill in your API keys +# Usage: docker compose -f docker-compose.yml -f docker-compose.mcp.yml --env-file .env.mcp --profile mcp up + +# ===== Slack ===== +SLACK_BOT_TOKEN=xoxb-your-slack-bot-token +SLACK_TEAM_ID=T00000000 + +# ===== Notion ===== +NOTION_API_KEY=ntn_your-notion-api-key + +# ===== Linear ===== +LINEAR_API_KEY=lin_api_your-linear-api-key + +# ===== Figma ===== +FIGMA_ACCESS_TOKEN=figd_your-figma-token + +# ===== HubSpot ===== +HUBSPOT_ACCESS_TOKEN=pat-na1-your-hubspot-token + +# ===== Atlassian (Jira/Confluence) ===== +ATLASSIAN_API_TOKEN=your-atlassian-api-token +ATLASSIAN_URL=https://yourteam.atlassian.net + +# ===== Asana ===== +ASANA_ACCESS_TOKEN=1/your-asana-access-token + +# ===== Intercom ===== +INTERCOM_ACCESS_TOKEN=your-intercom-access-token + +# ===== Snowflake ===== +SNOWFLAKE_ACCOUNT=your-account.region +SNOWFLAKE_USER=your-username +SNOWFLAKE_PASSWORD=your-password + +# ===== BigQuery ===== +# Path to your Google Cloud service account JSON key file +GCP_CREDENTIALS_PATH=./secrets/gcp-credentials.json + +# ===== Microsoft 365 ===== +MS365_CLIENT_ID=your-azure-ad-client-id +MS365_CLIENT_SECRET=your-azure-ad-client-secret + +# ===== Box ===== +BOX_CLIENT_ID=your-box-client-id +BOX_CLIENT_SECRET=your-box-client-secret + +# ===== Amplitude ===== +AMPLITUDE_API_KEY=your-amplitude-api-key + +# ===== Canva ===== +CANVA_ACCESS_TOKEN=your-canva-access-token + +# ===== Databricks ===== +DATABRICKS_HOST=https://your-workspace.cloud.databricks.com +DATABRICKS_TOKEN=dapiXXXXXXXXXXXXXXXX diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 000000000..93337d774 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,71 @@ +# Production Environment Configuration +# Copy this file to .env.production and update with your values + +# Application +ENVIRONMENT=production +RAILS_ENV=production +NODE_ENV=production + +# Registry and Images +REGISTRY_URL=your-registry.com +VERSION=main-latest + +# Domains +DOMAIN=powernode.io +ACME_EMAIL=admin@powernode.io + +# URLs +PRODUCTION_URL=https://powernode.io +BACKEND_URL=https://powernode.io/api +FRONTEND_URL=https://powernode.io +WORKER_URL=http://worker:4567 + +# Docker Compose +COMPOSE_PROJECT_NAME=powernode-production + +# Monitoring +PROMETHEUS_TOKEN= +PROMETHEUS_LTS_URL= +LOKI_URL=http://loki:3100 +GRAFANA_URL=https://grafana.powernode.io + +# Notifications +ALERT_FROM_EMAIL=alerts@powernode.io +CRITICAL_EMAIL=oncall@powernode.io +WARNING_EMAIL=ops@powernode.io +INFO_EMAIL=logs@powernode.io +DEFAULT_EMAIL=admin@powernode.io +DBA_EMAIL=dba@powernode.io +DEV_EMAIL=dev@powernode.io + +# SMTP (for production alerts) +SMTP_HOST=smtp.powernode.io:587 +SMTP_USERNAME=alerts +SMTP_PASSWORD= + +# External integrations +SLACK_WEBHOOK_URL= +PAGERDUTY_SERVICE_KEY= + +# Storage and backups +POSTGRES_BACKUP_RETENTION=30d +REDIS_BACKUP_RETENTION=7d +BACKUP_STORAGE_URL=s3://powernode-backups + +# Performance tuning (production) +RAILS_MAX_THREADS=10 +WEB_CONCURRENCY=2 +SIDEKIQ_CONCURRENCY=10 + +# Security (strict for production) +FORCE_SSL=true +RAILS_SERVE_STATIC_FILES=false +RAILS_LOG_LEVEL=info + +# CDN and caching +CDN_URL=https://cdn.powernode.io +CACHE_TTL=3600 + +# External services +EXTERNAL_API_URL= +EXTERNAL_API_KEY= \ No newline at end of file diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 000000000..da3faeb6b --- /dev/null +++ b/.env.staging.example @@ -0,0 +1,61 @@ +# Staging Environment Configuration +# Copy this file to .env.staging and update with your values + +# Application +ENVIRONMENT=staging +RAILS_ENV=staging +NODE_ENV=production + +# Registry and Images +REGISTRY_URL=your-registry.com +VERSION=develop-latest + +# Domains +STAGING_DOMAIN=staging.powernode.io +ACME_EMAIL=admin@powernode.io + +# URLs +STAGING_URL=https://staging.powernode.io +BACKEND_URL=https://staging.powernode.io/api +FRONTEND_URL=https://staging.powernode.io +WORKER_URL=http://worker:4567 + +# Docker Compose +COMPOSE_PROJECT_NAME=powernode-staging + +# Monitoring +PROMETHEUS_TOKEN= +LOKI_URL=http://loki:3100 +GRAFANA_URL=https://grafana-staging.powernode.io + +# Notifications +ALERT_FROM_EMAIL=alerts-staging@powernode.io +CRITICAL_EMAIL=oncall-staging@powernode.io +WARNING_EMAIL=ops-staging@powernode.io +INFO_EMAIL=logs-staging@powernode.io +DEFAULT_EMAIL=admin-staging@powernode.io +DBA_EMAIL=dba-staging@powernode.io +DEV_EMAIL=dev-staging@powernode.io + +# SMTP (for staging alerts) +SMTP_HOST=smtp.powernode.io:587 +SMTP_USERNAME=alerts-staging +SMTP_PASSWORD= + +# Optional: External services +SLACK_WEBHOOK_URL= +PAGERDUTY_SERVICE_KEY= + +# Storage +POSTGRES_BACKUP_RETENTION=7d +REDIS_BACKUP_RETENTION=3d + +# Performance tuning (staging) +RAILS_MAX_THREADS=10 +WEB_CONCURRENCY=1 +SIDEKIQ_CONCURRENCY=5 + +# Security (relaxed for staging) +FORCE_SSL=false +RAILS_SERVE_STATIC_FILES=true +RAILS_LOG_LEVEL=debug \ No newline at end of file diff --git a/.gitea/workflows/ai-agent-execution.yml b/.gitea/workflows/ai-agent-execution.yml new file mode 100644 index 000000000..6bacac020 --- /dev/null +++ b/.gitea/workflows/ai-agent-execution.yml @@ -0,0 +1,178 @@ +# AI Agent Execution Workflow +# Powernode AI Agent Community Platform +# +# This workflow executes AI agents in sandboxed containers with +# secure secret injection from HashiCorp Vault. +# +# Triggered via workflow_dispatch from ContainerOrchestrationService + +name: AI Agent Execution + +on: + workflow_dispatch: + inputs: + execution_id: + description: 'Unique execution ID for tracking' + required: true + type: string + container_image: + description: 'Container image to run' + required: true + type: string + account_id: + description: 'Account ID for secret scoping' + required: true + type: string + vault_token: + description: 'Short-lived Vault token for secret access' + required: true + type: string + input_parameters: + description: 'JSON-encoded input parameters' + required: false + type: string + default: '{}' + timeout_minutes: + description: 'Execution timeout in minutes' + required: false + type: number + default: 60 + callback_url: + description: 'URL to POST results' + required: true + type: string + +env: + EXECUTION_ID: ${{ inputs.execution_id }} + VAULT_ADDR: ${{ secrets.VAULT_ADDR }} + +jobs: + validate: + runs-on: [powernode-ai-agent] + outputs: + image_allowed: ${{ steps.validate.outputs.allowed }} + steps: + - name: Validate Container Image + id: validate + run: | + # Validate image is from allowed registry + ALLOWED_REGISTRIES="powernode/ ghcr.io/powernode/" + IMAGE="${{ inputs.container_image }}" + + ALLOWED="false" + for registry in $ALLOWED_REGISTRIES; do + if [[ "$IMAGE" == ${registry}* ]]; then + ALLOWED="true" + break + fi + done + + echo "allowed=$ALLOWED" >> $GITHUB_OUTPUT + + if [ "$ALLOWED" != "true" ]; then + echo "::error::Container image not from allowed registry: $IMAGE" + exit 1 + fi + + execute: + needs: validate + if: needs.validate.outputs.image_allowed == 'true' + runs-on: [powernode-ai-agent] + timeout-minutes: ${{ fromJSON(inputs.timeout_minutes) }} + + container: + image: ${{ inputs.container_image }} + options: >- + --read-only + --cap-drop=ALL + --security-opt=no-new-privileges:true + --memory=512m + --cpus=0.5 + --network=powernode-agent-network + + steps: + - name: Inject Secrets from Vault + id: secrets + uses: hashicorp/vault-action@v3 + with: + url: ${{ secrets.VAULT_ADDR }} + token: ${{ inputs.vault_token }} + caCertificate: ${{ secrets.VAULT_CA_CERT }} + secrets: | + secret/data/powernode/accounts/${{ inputs.account_id }}/ai-providers/openai api_key | OPENAI_API_KEY ; + secret/data/powernode/accounts/${{ inputs.account_id }}/ai-providers/anthropic api_key | ANTHROPIC_API_KEY ; + secret/data/powernode/containers/${{ inputs.execution_id }}/config * | AGENT_CONFIG + + - name: Prepare Execution Context + run: | + # Create temp directory for execution + mkdir -p /tmp/agent-work + + # Write input parameters + echo '${{ inputs.input_parameters }}' > /tmp/agent-work/input.json + + # Set execution metadata + echo "EXECUTION_ID=${{ inputs.execution_id }}" >> $GITHUB_ENV + echo "ACCOUNT_ID=${{ inputs.account_id }}" >> $GITHUB_ENV + + - name: Execute Agent + id: execute + run: | + # Run the agent entrypoint + /entrypoint.sh \ + --execution-id "${{ inputs.execution_id }}" \ + --input-file /tmp/agent-work/input.json \ + --output-file /tmp/agent-work/output.json + env: + OPENAI_API_KEY: ${{ steps.secrets.outputs.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ steps.secrets.outputs.ANTHROPIC_API_KEY }} + + - name: Capture Output + if: always() + id: output + run: | + if [ -f /tmp/agent-work/output.json ]; then + # Encode output for safe transmission + OUTPUT=$(cat /tmp/agent-work/output.json | base64 -w 0) + echo "output=$OUTPUT" >> $GITHUB_OUTPUT + else + echo "output=" >> $GITHUB_OUTPUT + fi + + - name: Report Results + if: always() + run: | + STATUS="${{ job.status }}" + OUTPUT="${{ steps.output.outputs.output }}" + + # Build result payload + PAYLOAD=$(cat <- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code with business submodule + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.ENTERPRISE_PAT }} + + - name: Checkout business branch + if: github.event.inputs.business_ref + run: | + cd business + git checkout ${{ github.event.inputs.business_ref }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + working-directory: server + + - name: Set up database + working-directory: server + env: + RAILS_ENV: test + DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} + REDIS_URL: redis://localhost:6379/0 + run: | + bundle exec rails db:create + bundle exec rails db:migrate + + - name: Verify business engine loaded + working-directory: server + env: + RAILS_ENV: test + DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} + REDIS_URL: redis://localhost:6379/0 + SECRET_KEY_BASE: test_secret_key_base + run: | + bundle exec rails runner "raise 'Business not loaded' unless defined?(PowernodeBusiness::Engine)" + + - name: Run RSpec tests (core + business) + working-directory: server + env: + RAILS_ENV: test + DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} + REDIS_URL: redis://localhost:6379/0 + SECRET_KEY_BASE: test_secret_key_base + JWT_SECRET: test_jwt_secret + run: bundle exec rspec --format progress + + frontend-tests: + name: Frontend Tests (Business) + runs-on: ubuntu-latest + + steps: + - name: Checkout code with business submodule + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.ENTERPRISE_PAT }} + + - name: Checkout business branch + if: github.event.inputs.business_ref + run: | + cd business + git checkout ${{ github.event.inputs.business_ref }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run TypeScript check (with business) + working-directory: frontend + run: npm run typecheck + + - name: Run ESLint + working-directory: frontend + run: npm run lint + + - name: Run Jest tests + working-directory: frontend + env: + CI: true + run: npm test -- --coverage --watchAll=false + + build-business-images: + name: Build Business Docker Images + runs-on: ubuntu-latest + needs: [backend-tests, frontend-tests] + + steps: + - name: Checkout code with business submodule + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.ENTERPRISE_PAT }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build backend image (business) + uses: docker/build-push-action@v5 + with: + context: . + file: ./server/Dockerfile + push: false + tags: powernode-backend-business:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build frontend image (business) + uses: docker/build-push-action@v5 + with: + context: . + file: ./frontend/Dockerfile + push: false + tags: powernode-frontend-business:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VITE_API_URL=https://api.example.com + VITE_WS_URL=wss://api.example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..5a750fd1e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,277 @@ +# Core CI Workflow +# Runs tests on the open-core codebase WITHOUT business submodule. +# Business CI runs separately in the private powernode-business repo. + +name: CI + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +env: + RUBY_VERSION: "3.2.8" + NODE_VERSION: "20" + POSTGRES_USER: powernode + POSTGRES_PASSWORD: powernode_test + POSTGRES_DB: powernode_test + +jobs: + # Backend Tests + backend-tests: + name: Backend Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + working-directory: server + + - name: Set up database + working-directory: server + env: + RAILS_ENV: test + DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} + REDIS_URL: redis://localhost:6379/0 + run: | + bundle exec rails db:create + bundle exec rails db:schema:load + + - name: Run RSpec tests + working-directory: server + env: + RAILS_ENV: test + DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} + REDIS_URL: redis://localhost:6379/0 + SECRET_KEY_BASE: test_secret_key_base + JWT_SECRET: test_jwt_secret + run: bundle exec rspec --format progress --exclude-pattern "**/channels/**/*_spec.rb" + + - name: Run security audit + working-directory: server + run: | + bundle exec brakeman --no-pager + bundle exec bundler-audit check --update + + # Worker Tests + worker-tests: + name: Worker Tests + runs-on: ubuntu-latest + + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + working-directory: worker + + - name: Run RSpec tests + working-directory: worker + env: + REDIS_URL: redis://localhost:6379/0 + BACKEND_API_URL: http://localhost:3000 + WORKER_API_KEY: test_worker_api_key + run: bundle exec rspec --format progress + + # Frontend Tests + frontend-tests: + name: Frontend Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run TypeScript check + working-directory: frontend + run: npm run typecheck + + - name: Run ESLint + working-directory: frontend + run: npm run lint + + - name: Run Jest tests + working-directory: frontend + env: + CI: true + run: npm test -- --coverage --watchAll=false + + # Code Quality & Pattern Validation + code-quality: + name: Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Check for hardcoded colors (theme violations) + working-directory: frontend + run: | + echo "Checking for hardcoded colors..." + # Check for hardcoded color classes that should use theme system + VIOLATIONS=$(grep -rn --include="*.tsx" --include="*.ts" \ + -E "(bg-blue|bg-red|bg-green|bg-yellow|bg-gray|text-blue|text-red|text-green|text-yellow|text-gray|border-blue|border-red|border-green|border-yellow|border-gray)-[0-9]+" \ + src/ 2>/dev/null | grep -v node_modules | grep -v ".test." || true) + if [ -n "$VIOLATIONS" ]; then + echo "::warning::Found hardcoded color classes (should use theme system):" + echo "$VIOLATIONS" + fi + + - name: Check for console.log statements + working-directory: frontend + run: | + echo "Checking for console.log statements..." + CONSOLE_LOGS=$(grep -rn --include="*.tsx" --include="*.ts" \ + "console\\.log" src/ 2>/dev/null | grep -v node_modules | grep -v ".test." || true) + if [ -n "$CONSOLE_LOGS" ]; then + echo "::warning::Found console.log statements:" + echo "$CONSOLE_LOGS" + fi + + - name: Check for role-based access (should use permissions) + working-directory: frontend + run: | + echo "Checking for role-based access control..." + # Check for direct role checks instead of permission checks + ROLE_CHECKS=$(grep -rn --include="*.tsx" --include="*.ts" \ + -E "(roles\\??\\.includes|role\\s*===|isAdmin|isManager)" \ + src/ 2>/dev/null | grep -v node_modules | grep -v ".test." || true) + if [ -n "$ROLE_CHECKS" ]; then + echo "::error::Found role-based access control (should use permissions):" + echo "$ROLE_CHECKS" + exit 1 + fi + + - name: Check Ruby frozen_string_literal pragma + run: | + echo "Checking for frozen_string_literal pragma..." + MISSING_PRAGMA=$(find server worker -name "*.rb" -type f \ + ! -path "*/vendor/*" ! -path "*/node_modules/*" \ + -exec sh -c 'head -1 "$1" | grep -q "frozen_string_literal" || echo "$1"' _ {} \; 2>/dev/null || true) + if [ -n "$MISSING_PRAGMA" ]; then + echo "::warning::Ruby files missing frozen_string_literal pragma:" + echo "$MISSING_PRAGMA" + fi + + - name: Validate API response patterns + run: | + echo "Checking for direct render calls..." + # Check that controllers use render_success/render_error + DIRECT_RENDERS=$(grep -rn --include="*.rb" \ + "render json:" server/app/controllers/ 2>/dev/null | \ + grep -v "render_success\|render_error\|render_paginated" || true) + if [ -n "$DIRECT_RENDERS" ]; then + echo "::warning::Found direct render json calls (should use render_success/render_error):" + echo "$DIRECT_RENDERS" + fi + + # Build Docker Images + build-images: + name: Build Docker Images + runs-on: ubuntu-latest + needs: [backend-tests, worker-tests, frontend-tests, code-quality] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build backend image + uses: docker/build-push-action@v5 + with: + context: ./server + file: ./server/Dockerfile + push: false + tags: powernode-backend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build worker image + uses: docker/build-push-action@v5 + with: + context: ./worker + file: ./worker/Dockerfile + push: false + tags: powernode-worker:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build frontend image + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: false + tags: powernode-frontend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VITE_API_URL=https://api.example.com + VITE_WS_URL=wss://api.example.com diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..435a85fca --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,206 @@ +# Deployment Workflow +# Deploys to staging on develop branch, production on main branch + +name: Deploy + +on: + push: + branches: + - main + - develop + workflow_dispatch: + inputs: + environment: + description: "Environment to deploy to" + required: true + default: "staging" + type: choice + options: + - staging + - production + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # Determine deployment environment + setup: + name: Setup Deployment + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.set-env.outputs.environment }} + domain: ${{ steps.set-env.outputs.domain }} + steps: + - name: Determine environment + id: set-env + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT + elif [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "environment=production" >> $GITHUB_OUTPUT + else + echo "environment=staging" >> $GITHUB_OUTPUT + fi + + # Build and push Docker images + build: + name: Build & Push Images + runs-on: ubuntu-latest + needs: setup + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (backend) + id: meta-backend + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend + tags: | + type=sha + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push backend + uses: docker/build-push-action@v5 + with: + context: ./server + file: ./server/Dockerfile + target: production + push: true + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata (worker) + id: meta-worker + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/worker + tags: | + type=sha + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push worker + uses: docker/build-push-action@v5 + with: + context: ./worker + file: ./worker/Dockerfile + target: production + push: true + tags: ${{ steps.meta-worker.outputs.tags }} + labels: ${{ steps.meta-worker.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata (frontend) + id: meta-frontend + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend + tags: | + type=sha + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push frontend + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: ${{ steps.meta-frontend.outputs.tags }} + labels: ${{ steps.meta-frontend.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VITE_API_URL=${{ secrets.VITE_API_URL }} + VITE_WS_URL=${{ secrets.VITE_WS_URL }} + + # Deploy to environment + deploy: + name: Deploy to ${{ needs.setup.outputs.environment }} + runs-on: ubuntu-latest + needs: [setup, build] + environment: ${{ needs.setup.outputs.environment }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }} + + - name: Add host to known_hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy with Docker Compose + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} + SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + WORKER_API_KEY: ${{ secrets.WORKER_API_KEY }} + DOMAIN: ${{ secrets.DOMAIN }} + run: | + # Copy compose file + scp docker-compose.prod.yml $DEPLOY_USER@$DEPLOY_HOST:~/powernode/ + + # Deploy + ssh $DEPLOY_USER@$DEPLOY_HOST << 'DEPLOY_SCRIPT' + cd ~/powernode + + # Pull latest images + docker compose -f docker-compose.prod.yml pull + + # Deploy with zero-downtime + docker compose -f docker-compose.prod.yml up -d --remove-orphans + + # Run database migrations + docker compose -f docker-compose.prod.yml exec -T backend bundle exec rails db:migrate + + # Cleanup old images + docker image prune -f + DEPLOY_SCRIPT + + - name: Health check + run: | + sleep 30 + curl -f https://api.${{ secrets.DOMAIN }}/health || exit 1 + curl -f https://${{ secrets.DOMAIN }} || exit 1 + + - name: Notify on success + if: success() + run: | + echo "Deployment to ${{ needs.setup.outputs.environment }} completed successfully!" + + - name: Notify on failure + if: failure() + run: | + echo "Deployment to ${{ needs.setup.outputs.environment }} failed!" + # Add Slack/Discord notification here diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml new file mode 100644 index 000000000..7d0a0b2b4 --- /dev/null +++ b/.github/workflows/rollback.yml @@ -0,0 +1,224 @@ +# Deployment Rollback Workflow +# Allows manual rollback to a previous deployment + +name: Rollback + +on: + workflow_dispatch: + inputs: + environment: + description: "Environment to rollback" + required: true + type: choice + options: + - staging + - production + target_version: + description: "Git SHA or tag to rollback to (leave empty for previous)" + required: false + type: string + include_database: + description: "Rollback database migrations (DANGEROUS)" + required: false + type: boolean + default: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # Confirm rollback + confirm: + name: Confirm Rollback + runs-on: ubuntu-latest + outputs: + approved: ${{ steps.approval.outputs.approved }} + steps: + - name: Request approval + id: approval + run: | + echo "::warning::Rolling back ${{ github.event.inputs.environment }} to ${{ github.event.inputs.target_version || 'previous version' }}" + echo "approved=true" >> $GITHUB_OUTPUT + + # Get rollback target + prepare: + name: Prepare Rollback + runs-on: ubuntu-latest + needs: confirm + outputs: + target_sha: ${{ steps.get-target.outputs.sha }} + current_sha: ${{ steps.get-current.outputs.sha }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get current deployment SHA + id: get-current + run: | + # In a real scenario, this would query your deployment tracking system + echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT + + - name: Get target SHA + id: get-target + run: | + if [ -n "${{ github.event.inputs.target_version }}" ]; then + # Use specified version + TARGET="${{ github.event.inputs.target_version }}" + else + # Get previous deployment (second most recent tag) + TARGET=$(git tag --sort=-creatordate | head -n 2 | tail -n 1) + if [ -z "$TARGET" ]; then + # Fallback to previous commit + TARGET=$(git rev-parse HEAD~1) + fi + fi + echo "Rollback target: $TARGET" + echo "sha=$TARGET" >> $GITHUB_OUTPUT + + # Create database backup before rollback + backup: + name: Backup Database + runs-on: ubuntu-latest + needs: prepare + if: github.event.inputs.include_database == 'true' + environment: ${{ github.event.inputs.environment }} + steps: + - name: Create pre-rollback backup + run: | + echo "Creating database backup before rollback..." + # SSH to server and run backup + # This would use the backup script created earlier + + # Perform rollback + rollback: + name: Rollback to ${{ needs.prepare.outputs.target_sha }} + runs-on: ubuntu-latest + needs: [prepare, backup] + if: always() && needs.prepare.result == 'success' && (needs.backup.result == 'success' || needs.backup.result == 'skipped') + environment: ${{ github.event.inputs.environment }} + + steps: + - name: Checkout target version + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.target_sha }} + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }} + + - name: Add host to known_hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if target images exist + id: check-images + run: | + TARGET_SHA="${{ needs.prepare.outputs.target_sha }}" + SHORT_SHA="${TARGET_SHA:0:7}" + + # Check if images exist for target SHA + if docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend:sha-${SHORT_SHA} > /dev/null 2>&1; then + echo "images_exist=true" >> $GITHUB_OUTPUT + echo "image_tag=sha-${SHORT_SHA}" >> $GITHUB_OUTPUT + else + echo "images_exist=false" >> $GITHUB_OUTPUT + echo "::error::No images found for SHA ${TARGET_SHA}" + fi + + - name: Rollback deployment + if: steps.check-images.outputs.images_exist == 'true' + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + IMAGE_TAG: ${{ steps.check-images.outputs.image_tag }} + run: | + ssh $DEPLOY_USER@$DEPLOY_HOST << ROLLBACK_SCRIPT + cd ~/powernode + + # Update image tags in environment + export BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend:${IMAGE_TAG} + export WORKER_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/worker:${IMAGE_TAG} + export FRONTEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend:${IMAGE_TAG} + + # Pull rollback images + docker pull \$BACKEND_IMAGE + docker pull \$WORKER_IMAGE + docker pull \$FRONTEND_IMAGE + + # Update compose file with new image tags + sed -i "s|image: .*backend:.*|image: \$BACKEND_IMAGE|g" docker-compose.prod.yml + sed -i "s|image: .*worker:.*|image: \$WORKER_IMAGE|g" docker-compose.prod.yml + sed -i "s|image: .*frontend:.*|image: \$FRONTEND_IMAGE|g" docker-compose.prod.yml + + # Deploy rollback version + docker compose -f docker-compose.prod.yml up -d --remove-orphans + + echo "Rollback deployment completed" + ROLLBACK_SCRIPT + + - name: Rollback database migrations + if: github.event.inputs.include_database == 'true' + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + run: | + echo "::warning::Database migration rollback requested" + ssh $DEPLOY_USER@$DEPLOY_HOST << 'DB_ROLLBACK' + cd ~/powernode + + # Get the migration version from the target deployment + # This is dangerous and should be carefully reviewed + docker compose -f docker-compose.prod.yml exec -T backend \ + bundle exec rails db:rollback STEP=1 + + echo "Database rollback completed (1 migration)" + DB_ROLLBACK + + - name: Health check + run: | + sleep 30 + if ! curl -f https://api.${{ secrets.DOMAIN }}/health; then + echo "::error::Health check failed after rollback!" + exit 1 + fi + echo "Health check passed" + + - name: Record rollback + run: | + echo "Rollback completed successfully" + echo "Environment: ${{ github.event.inputs.environment }}" + echo "From: ${{ needs.prepare.outputs.current_sha }}" + echo "To: ${{ needs.prepare.outputs.target_sha }}" + echo "Database rollback: ${{ github.event.inputs.include_database }}" + + # Notify on completion + notify: + name: Send Notifications + runs-on: ubuntu-latest + needs: [prepare, rollback] + if: always() + steps: + - name: Notify success + if: needs.rollback.result == 'success' + run: | + echo "✅ Rollback to ${{ needs.prepare.outputs.target_sha }} completed successfully" + # Add Slack/Discord notification here + + - name: Notify failure + if: needs.rollback.result == 'failure' + run: | + echo "❌ Rollback to ${{ needs.prepare.outputs.target_sha }} failed" + # Add Slack/Discord notification here diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 000000000..62c59a0be --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,118 @@ +# Security Scanning Workflow +# Runs security checks on a schedule and on PRs + +name: Security + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + # Run weekly on Monday at 9 AM UTC + - cron: "0 9 * * 1" + +jobs: + # Backend security scanning + backend-security: + name: Backend Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2.8" + bundler-cache: true + working-directory: server + + - name: Run Brakeman (SAST) + working-directory: server + run: | + bundle exec brakeman --format json --output brakeman-report.json || true + bundle exec brakeman --format html --output brakeman-report.html || true + + - name: Upload Brakeman report + uses: actions/upload-artifact@v4 + with: + name: brakeman-report + path: | + server/brakeman-report.json + server/brakeman-report.html + + - name: Run Bundle Audit + working-directory: server + run: bundle exec bundler-audit check --update + + # Frontend security scanning + frontend-security: + name: Frontend Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run npm audit + working-directory: frontend + run: npm audit --audit-level=high || true + + - name: Run security lint + working-directory: frontend + run: npm run lint:security || true + + # Docker image scanning + container-security: + name: Container Security Scan + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build backend image + run: docker build -t powernode-backend:scan ./server + + - name: Run Trivy on backend + uses: aquasecurity/trivy-action@master + with: + image-ref: "powernode-backend:scan" + format: "sarif" + output: "trivy-backend.sarif" + severity: "CRITICAL,HIGH" + + - name: Upload Trivy scan results + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: "trivy-backend.sarif" + + # Dependency review for PRs + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Dependency Review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..370c31bba --- /dev/null +++ b/.gitignore @@ -0,0 +1,328 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config. +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 +/db/*.sqlite3-journal + +# Ignore all logfiles and tempfiles. +/log/* +!/log/.keep +/tmp/* +!/tmp/.keep + +# Ignore uploaded files in development. +/storage/* +!/storage/.keep + +# Ignore assets. +/public/assets/ + +# === SECURITY: Environment and Secret Files === +# Environment files (comprehensive protection) +.env +.env.* +!.env.example +!.env.*.example +!.env.sample +!.env.template + +# Rails secrets and encryption keys +config/master.key +config/credentials.yml.enc +config/credentials/ +*.key +*.pem +*.p12 +*.p7b +*.pfx +*.jks +*.keystore + +# Session and encryption keys +.session.key +session.key +encryption.key +*session*.key + +# API keys and tokens +*secret* +!**/secret_manager.rb +*private* +*.token +*.tokens +api_keys.yml +api_keys.json +credentials.json +auth.json + +# Kamal deployment secrets +.kamal/secrets +.kamal/.env +kamal.env + +# Docker secrets and configs +docker/secrets/ +.docker/config.json +.docker/daemon.json + +# Temporary secret files +tmp/*secret* +tmp/*key* +tmp/*credential* +tmp/*token* +tmp/.env* + +# Database dumps with potential secrets +*.sql +*.dump +*.db +*.sqlite* +backup/ +backups/ +dumps/ +database_backups/ + +# Development database files (specific SQLite files) +development.sqlite3 +test.sqlite3 +*.sqlite3-journal + +# SSL/TLS certificates and security files +*.crt +*.cert +*.cer +*.ca-bundle +*.ca +*.ca-cert +*.chain +ssl/ +certificates/ +certs/ +tls/ + +# Test credentials (generated by rails db:seed) +test-credentials.json + +# Cloud provider credentials +.aws/ +.gcp/ +.gcloud/ +.azure/ +*-credentials.json +service-account.json +service-account-key.json +gcp-*.json +aws-*.json + +# GitHub and CI/CD secrets +.github/secrets/ +.secrets/ +secrets/ +secrets.yml +secrets.json + +# Kubernetes secrets +k8s/secrets/ +kubernetes/secrets/ +*-secret.yaml +*-secret.yml + +# Terraform sensitive files +*.tfvars +terraform.tfstate* +.terraform/ + +# HashiCorp Vault +.vault-token +vault-* + +# SSH keys and certificates +id_rsa* +id_dsa* +id_ecdsa* +id_ed25519* +*.pub +known_hosts +authorized_keys + +# Payment gateway specific +stripe_* +paypal_* +*_stripe_* +*_paypal_* + +# Ignore node_modules +node_modules/ +/node_modules + +# Ignore compiled assets +/public/packs +/public/packs-test +/public/assets + +# Logs and operational files (consolidated) +*.log +*.log.* +logs/ +log/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.pid +*.pid.lock + +# Ignore yarn files +.pnp +.pnp.js + +# Coverage directories and test reports (consolidated) +coverage/ +.coverage/ +/coverage/ +.nyc_output +.nyc_output/ +test-results/ +cypress/screenshots/ +cypress/videos/ +cypress/downloads/ +spec/reports/ +junit.xml + +# Playwright +playwright-report/ +playwright/.cache/ +e2e/.auth/ +blob-report/ + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# Ignore parcel-bundler cache +.cache +.parcel-cache + +# Ignore Next.js build output +.next + +# Ignore Nuxt.js build / generate output +.nuxt +dist + +# Ignore Gatsby files +.cache/ +/public + +# Ignore Vuepress build output +.vuepress/dist + +# Ignore Serverless directories +.serverless/ + +# Ignore FuseBox cache +.fusebox/ + +# Ignore DynamoDB Local files +.dynamodb/ + +# Ignore TernJS port file +.tern-port + +# Claude Flow system metrics and cache +.claude-flow/ + +# Claude Code worktrees (temporary isolated git worktrees from agent execution) +.claude/worktrees/ +.claude/*.lock + +# MCP configuration (generated per-environment via: scripts/manage-proxy-hosts.sh generate-mcp) +.mcp.json + +# IDE and editor files (consolidated) +.vscode/ +.idea/ +*.iml +*.ipr +*.iws +.idea/dataSources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Backup and temporary files (consolidated) +*.bak +*.backup +*.old +*.orig +*.rej +*.swp +*.swo +.*.swp +.*.swo +*.tmp +*.temp +*.un~ +*~ +.#* +\#*\# +\#*# + +# Archive files that might contain sensitive data +*.tar.gz +*.zip +*.7z +*.rar +exports/ +export.json +export.csv + +# Defunct swarm tooling (replaced by MCP shared memory) +.swarm/ + +# Security scan reports (generated locally) +security-reports/ + +# RSpec auto-generated example files +worker/spec/examples.txt + +# Ad-hoc test scripts with potential secrets +server/scripts/test_*.sh +server/scripts/trigger_*.sh + +# Test output artifacts +rspec_results.json \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..2d9ed888b --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,171 @@ +# Gitleaks Configuration for Powernode Platform +# https://github.com/gitleaks/gitleaks +# Run: gitleaks detect --config .gitleaks.toml + +title = "Powernode Secret Scanner" + +[extend] +# Extend default gitleaks rules +useDefault = true + +# Custom rules for Powernode-specific secrets +[[rules]] +id = "powernode-jwt-secret" +description = "Powernode JWT Secret" +regex = '''(?i)(jwt[_-]?secret|secret[_-]?key[_-]?base)\s*[:=]\s*['"]?([a-zA-Z0-9+/=]{32,})['"]?''' +tags = ["jwt", "secret", "powernode"] + +[[rules]] +id = "powernode-encryption-key" +description = "Rails Encryption Keys" +regex = '''(?i)(primary[_-]?key|deterministic[_-]?key|key[_-]?derivation[_-]?salt)\s*[:=]\s*['"]?([a-zA-Z0-9+/=]{32,})['"]?''' +tags = ["encryption", "rails", "powernode"] + +[[rules]] +id = "stripe-secret-key" +description = "Stripe Secret Key" +regex = '''(?i)sk_live_[a-zA-Z0-9]{24,}''' +tags = ["stripe", "payment", "critical"] + +[[rules]] +id = "stripe-test-key" +description = "Stripe Test Key" +regex = '''(?i)sk_test_[a-zA-Z0-9]{24,}''' +tags = ["stripe", "payment", "test"] + +[[rules]] +id = "paypal-client-secret" +description = "PayPal Client Secret" +regex = '''(?i)(paypal[_-]?client[_-]?secret|paypal[_-]?secret)\s*[:=]\s*['"]?([a-zA-Z0-9_-]{40,})['"]?''' +tags = ["paypal", "payment", "critical"] + +[[rules]] +id = "twilio-auth-token" +description = "Twilio Auth Token" +regex = '''(?i)(twilio[_-]?auth[_-]?token|twilio[_-]?token)\s*[:=]\s*['"]?([a-f0-9]{32})['"]?''' +tags = ["twilio", "sms", "critical"] + +[[rules]] +id = "firebase-api-key" +description = "Firebase API Key" +regex = '''(?i)AIza[0-9A-Za-z_-]{35}''' +tags = ["firebase", "google", "critical"] + +[[rules]] +id = "database-url" +description = "Database Connection URL" +regex = '''(?i)(postgres|mysql|mongodb)://[^:]+:[^@]+@[^/]+/[^\s'"]+''' +tags = ["database", "credentials", "critical"] + +[[rules]] +id = "redis-url" +description = "Redis Connection URL with Password" +regex = '''(?i)redis://:[^@]+@[^/]+''' +tags = ["redis", "credentials"] + +[[rules]] +id = "sendgrid-api-key" +description = "SendGrid API Key" +regex = '''SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}''' +tags = ["sendgrid", "email", "critical"] + +[[rules]] +id = "aws-access-key" +description = "AWS Access Key ID" +regex = '''(?i)AKIA[0-9A-Z]{16}''' +tags = ["aws", "cloud", "critical"] + +[[rules]] +id = "aws-secret-key" +description = "AWS Secret Access Key" +regex = '''(?i)(aws[_-]?secret[_-]?access[_-]?key|aws[_-]?secret[_-]?key)\s*[:=]\s*['"]?([a-zA-Z0-9+/]{40})['"]?''' +tags = ["aws", "cloud", "critical"] + +[[rules]] +id = "private-key" +description = "Private Key" +regex = '''-----BEGIN (RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY-----''' +tags = ["key", "critical"] + +[[rules]] +id = "generic-api-key" +description = "Generic API Key Pattern" +regex = '''(?i)(api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*['"]?([a-zA-Z0-9_-]{20,})['"]?''' +tags = ["api", "generic"] + +[[rules]] +id = "oauth-client-secret" +description = "OAuth Client Secret" +regex = '''(?i)(client[_-]?secret|oauth[_-]?secret)\s*[:=]\s*['"]?([a-zA-Z0-9_-]{20,})['"]?''' +tags = ["oauth", "critical"] + +# Allowlist - paths and patterns to ignore +[allowlist] +description = "Global Allowlist" + +# Ignore specific files and directories +paths = [ + '''\.gitleaks\.toml$''', + '''\.env\.example$''', + '''\.env\.sample$''', + '''\.env\.template$''', + '''\.env\..*\.example$''', + '''config/credentials\.yml\.enc$''', + '''config/master\.key\.example$''', + '''spec/''', + '''test/''', + '''cypress/''', + '''e2e/''', + '''docs/''', + '''node_modules/''', + '''vendor/''', + '''tmp/''', + '''log/''', + '''server/db/seeds/''', + '''scripts/test_''', + '''scripts/security-cleanup\.sh$''', + '''\.github/workflows/''', + '''server/\.github/workflows/''', + '''docker-compose.*\.yml$''', + '''docker/''', + '''scripts/systemd/configs/''', + '''\.test\.(ts|tsx|js|jsx)$''', +] + +# Ignore specific regex patterns in content +regexes = [ + '''(?i)example[_-]?key''', + '''(?i)placeholder''', + '''(?i)your[_-]?api[_-]?key''', + '''(?i)xxx+''', + '''(?i)fake[_-]?secret''', + '''(?i)test[_-]?secret''', + '''(?i)dummy''', + '''REPLACE_ME''', + '''''', + '''(?i)YOUR_JWT_TOKEN''', + '''(?i)YOUR_TOKEN''', + '''(?i)YOUR_ADMIN_KEY''', + '''(?i)YOUR_REFRESH_TOKEN''', + '''(?i)ADMIN_API_KEY''', + '''(?i)ADMIN_KEY''', + '''(?i)mock[_-]?token''', + '''\$\{\{?\s*env\.\w+\s*\}?\}''', + '''\$\{[A-Z_]+\}''', + '''\$\$\(cat /run/secrets/''', +] + +# Commits to ignore (add SHA hashes of commits with false positives) +commits = [] + +# Stop words - ignore secrets containing these +stopwords = [ + "example", + "placeholder", + "your_api_key", + "test_key", + "dummy", + "fake", + "sample", + "redacted", +] diff --git a/.gitmessage b/.gitmessage new file mode 100644 index 000000000..f387035d0 --- /dev/null +++ b/.gitmessage @@ -0,0 +1,20 @@ +# [optional scope][!]: +# +# [optional body] +# +# [optional footer(s)] + +# Example: +# feat(auth): add OAuth2 integration +# +# Implement OAuth2 authentication flow with support for +# Google and GitHub providers. +# +# Closes #123 +# BREAKING CHANGE: Authentication API endpoints restructured + +# Types: feat, fix, docs, style, refactor, test, chore, ci, perf, revert +# Use ! after type/scope to indicate breaking changes +# Keep subject line under 50 characters +# Use imperative mood: "add" not "added" or "adds" +# Reference issues and breaking changes in footer \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..d25b3cf0d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "extensions/supply-chain"] + path = extensions/supply-chain + url = git@git.ipnode.net:powernode/powernode-supply-chain.git + branch = develop +[submodule "extensions/trading"] + path = extensions/trading + url = git@git.ipnode.net:powernode/powernode-trading.git + branch = develop +[submodule "extensions/business"] + path = extensions/business + url = git@git.ipnode.net:powernode/powernode-business.git + branch = develop diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 000000000..b8066385d --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,158 @@ +{ + "branches": [ + "main", + { + "name": "develop", + "prerelease": "beta" + }, + { + "name": "release/*", + "prerelease": "rc" + } + ], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + [ + "@semantic-release/changelog", + { + "changelogFile": "docs/CHANGELOG.md", + "changelogTitle": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." + } + ], + [ + "@semantic-release/npm", + { + "npmPublish": false, + "pkgRoot": "frontend" + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "docs/CHANGELOG.md", + "package.json", + "frontend/package.json", + "server/Gemfile", + "worker/Gemfile", + "CLAUDE.md" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@semantic-release/github", + { + "successComment": "🎉 This issue has been resolved in version ${nextRelease.version}. The release is available on [GitHub release]()", + "failComment": "❌ The release from branch ${branch.name} had failed due to the following errors:\n- ${errors.map(err => err.message).join('\\n- ')}", + "releasedLabels": ["released<%= nextRelease.channel ? `on @\${nextRelease.channel}` : \"\" %>"], + "addReleases": "bottom" + } + ] + ], + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "✨ Features", + "hidden": false + }, + { + "type": "fix", + "section": "🐛 Bug Fixes", + "hidden": false + }, + { + "type": "perf", + "section": "🚀 Performance Improvements", + "hidden": false + }, + { + "type": "revert", + "section": "🗑 Reverts", + "hidden": false + }, + { + "type": "docs", + "section": "📚 Documentation", + "hidden": false + }, + { + "type": "style", + "section": "💎 Styles", + "hidden": true + }, + { + "type": "chore", + "section": "♻️ Chores", + "hidden": true + }, + { + "type": "refactor", + "section": "📦 Code Refactoring", + "hidden": false + }, + { + "type": "test", + "section": "🚨 Tests", + "hidden": true + }, + { + "type": "ci", + "section": "⚙️ Continuous Integration", + "hidden": true + } + ] + }, + "releaseRules": [ + { + "type": "feat", + "release": "minor" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "perf", + "release": "patch" + }, + { + "type": "revert", + "release": "patch" + }, + { + "type": "docs", + "release": "patch" + }, + { + "type": "refactor", + "release": "patch" + }, + { + "type": "style", + "release": false + }, + { + "type": "test", + "release": false + }, + { + "type": "ci", + "release": false + }, + { + "type": "chore", + "release": false + }, + { + "breaking": true, + "release": "major" + } + ], + "tagFormat": "v${version}", + "repositoryUrl": "https://github.com/powernode/powernode-platform", + "debug": true +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..0abf1b71e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,165 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Future features will be listed here + +### Changed +- Future changes will be listed here + +### Fixed +- Future fixes will be listed here + +## [0.0.2] - 2025-08-24 + +### Added +- **Marketplace Infrastructure**: Complete app marketplace with 13 database tables +- **App Management**: Full CRUD operations for apps, plans, subscriptions, and features +- **API Endpoints**: 7 new controllers with comprehensive marketplace operations +- **Frontend Components**: 40+ new components for marketplace UI and management +- **Webhook System**: Complete webhook management with delivery tracking +- **Endpoint Management**: API endpoint configuration and analytics +- **App Analytics**: Comprehensive metrics and performance tracking +- **Permission System**: 47 new marketplace-specific permissions with audit logging +- **Database Migrations**: 4 new migrations for marketplace infrastructure +- **Documentation**: Comprehensive marketplace implementation guides and API docs + +### Changed +- **Code Quality**: Fixed 51 files with ESLint warnings (94 → 14 warnings) +- **Performance**: Added useCallback/useMemo optimizations across components +- **Navigation**: Updated structure with marketplace routes and improved UX +- **Component Architecture**: Enhanced PageContainer and TabContainer patterns +- **Database Schema**: Updated to version 2025_08_24_040830 + +### Fixed +- **TypeScript Compilation**: Resolved TS2554 and TS2304 errors in admin components +- **React Hooks**: Fixed no-use-before-define warnings by reordering function definitions +- **Template Strings**: Fixed expression warnings in fix-compilation-errors.ts +- **DateRangeFilter**: Major reorganization to resolve multiple hook dependency issues +- **AdminAPI**: Updated getUsers() method to accept optional filters parameter +- **Unused Variables**: Cleaned up unused imports and variables across codebase + +### Technical Details +- **Files Changed**: 135 files with +23,580 insertions, -297 deletions +- **Test Coverage**: All tests passing (Frontend 19/19, Backend 921/921) +- **Code Quality**: Zero TypeScript compilation errors +- **Performance**: 84% reduction in ESLint warnings +- **Architecture**: Complete marketplace infrastructure ready for production + +## [0.0.1] - 2025-08-15 + +### Added +- Initial platform foundation with Rails 8 API backend +- React TypeScript frontend with modern component architecture +- Sidekiq worker service for background job processing +- JWT authentication system with secure token handling +- Comprehensive subscription lifecycle management +- Payment gateway integrations (Stripe, PayPal) +- Money gem integration for precise financial calculations +- UUIDv7 primary keys for all database entities +- State machine implementations for subscription management +- Comprehensive audit logging system +- User management with role-based permissions +- Account delegation and impersonation capabilities +- Global notification system with theme-aware components +- Analytics dashboard with real-time metrics +- Admin panel with security settings and system management +- Email configuration and template management +- Comprehensive test suite (921+ backend, 45+ frontend tests) +- Git-Flow workflow with semantic versioning enforcement +- Development environment automation scripts +- Comprehensive documentation and setup guides + +### Changed +- Renamed services to workers for architectural clarity +- Enhanced authentication and security features +- Improved API error handling and validation +- Database schema optimizations +- Theme-aware component styling throughout platform + +### Fixed +- Analytics dashboard date range button functionality +- Component import/export consistency +- TypeScript compilation errors +- Security vulnerabilities in error handling + +### Security +- Enhanced authentication flow validation +- Improved error message sanitization +- Rate limiting implementation +- Secure JWT token handling +- PCI-compliant payment processing +- Cross-origin request protection (CORS) +- Input validation and sanitization +- Secure email delivery system + +### Infrastructure +- PostgreSQL database with optimized schema +- Redis for caching and session management +- Comprehensive development scripts +- Docker-ready configuration +- CI/CD pipeline foundation +- Automated testing and validation + +## [0.0.1-dev] - 2024-12-XX + +### Added +- Initial platform foundation +- Rails 8 API backend with core models +- React TypeScript frontend +- Sidekiq worker service +- JWT authentication system +- UUIDv7 primary keys +- Money gem integration +- State machine implementations +- Comprehensive test suite (203+ backend, 45+ frontend tests) + +### Infrastructure +- PostgreSQL database setup +- Development environment automation +- Docker configuration +- CI/CD pipeline foundation + +--- + +## Version History + +- `0.0.1-dev` - Initial development version +- `0.1.0` - Planned first minor release + +## Release Notes Template + +```markdown +## [X.Y.Z] - YYYY-MM-DD + +### Added +- New features + +### Changed +- Changes in existing functionality + +### Deprecated +- Soon-to-be removed features + +### Removed +- Now removed features + +### Fixed +- Bug fixes + +### Security +- Security improvements +``` + +## Migration Guides + +### Upgrading to 0.1.0 (Planned) +- Migration steps will be documented here +- Breaking changes and how to address them +- Updated API endpoints and parameters \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index aa9597aeb..0c4446faa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,151 +1,551 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Development guidance for **Powernode** subscription platform. ## Project Overview -This is **Powernode** built with: -- **Backend**: Ruby on Rails 8 API-only application (located in `./server` directory) -- **Frontend**: ReactJS with TypeScript (located in `./frontend` directory) -- **Database**: PostgreSQL -- **Payments**: Stripe (primary), PayPal (secondary) -- **Background Jobs**: Sidekiq (standalone agent with API-only connectivity) -- **Testing**: RSpec (backend), Jest/Testing Library/Cypress (frontend) - -The platform handles subscription lifecycle management, automated billing, payment processing, proration calculations, dunning management, and comprehensive analytics. - -## Architecture Overview - -### Backend Structure (Rails 8 API) -- **Models**: Account, User, Role, Permission, Invitation, AccountDelegation, Subscription, Plan, Invoice, Payment, AuditLog with complex associations - - Users are associated with an Account (accounts may have multiple users) - - Role-based access control with Users having Roles and Permissions - - Subscriptions are associated with a Plan - - Plans have configurable default roles - - Plans include features and limits stored as hash - - Default roles from plan are assigned to user on account creation - - First created user becomes account owner - - Invitations for new user invitees - - Account Delegation support to allow existing users from different accounts - - State machine functionality for subscription and payment states - - Audit logging for all model changes and user actions -- **Primary Keys**: Use application-generated UUIDv7 for all primary keys -- **Authentication**: JWT authentication for API access -- **Services**: Payment processing, billing calculations, dunning management -- **Jobs**: Automated renewals, payment retries, notification sending -- **Controllers**: RESTful API endpoints with proper serialization -- **Webhooks**: Payment gateway event handling (Stripe, PayPal) - -### Frontend Structure (React + TypeScript) -- **Pages**: Customer dashboard, admin panel, billing management, application settings, user management, account management, subscription management, invitations management, delegations management, payment processor management -- **Components**: Reusable UI components with accessibility -- **Store**: Redux/Context for state management -- **Services**: API integration and data fetching -- **Application Settings**: All application settings manageable from within the frontend - -### Key Business Logic Areas -- **Subscription Lifecycle**: Creation, upgrades/downgrades, pausing, cancellation -- **Billing Engine**: Automated renewals, proration calculations, invoice generation -- **Payment Processing**: Gateway integrations, retry logic, webhook handling -- **Dunning Management**: Failed payment recovery, account suspension -- **Analytics**: MRR/ARR calculations, churn analysis, customer lifetime value - -## Development Workflow - -### Project Status Tracking -- Main task tracking should be maintained in a TODO.md file with development tasks -- Tasks use status indicators: `[ ]` PENDING, `[🔄]` IN_PROGRESS, `[✅]` COMPLETED, `[❌]` BLOCKED, `[⚠️]` NEEDS_REVIEW -- Always update task status when working on related features - -### Development Commands -Since the project is in early stages with no existing Rails/React setup: -- **Rails Setup**: `cd server && rails new . --api --database=postgresql` (when creating backend in ./server directory) -- **React Setup**: `npx create-react-app frontend --template typescript` (when creating frontend in ./frontend directory) -- **Database**: Standard Rails commands from server directory (`cd server && rails db:create`, `rails db:migrate`, `rails db:seed`) -- **Testing**: `cd server && rspec` for backend, `cd frontend && npm test` for frontend when setup -- **Background Jobs**: Standalone agent approach - see Background Jobs Architecture section below - -### Multi-Agent Coordination -The project uses a sophisticated agent-based development approach defined in `claude-swarm.yml`: -- **Backend Agents**: Rails architect, data modeler, payment specialist, billing engine developer -- **Frontend Agents**: React architect, UI developer, dashboard specialist, admin panel developer -- **Quality Agents**: Backend/frontend test engineers -- **Infrastructure Agents**: DevOps engineer, security specialist, performance optimizer - -## Key Implementation Patterns - -### Payment Integration -- Use service objects for payment gateway interactions -- Implement comprehensive webhook handling for payment events -- Store payment methods securely with PCI compliance considerations -- Handle payment retries with exponential backoff - -### Subscription Management -- Model subscription states as state machines -- Implement proration calculations for mid-cycle changes -- Use background jobs for automated renewal processing -- Track subscription history for audit purposes - -### Security Considerations -- JWT authentication for API access -- **Password Security**: Strong password complexity requirements enforced - - Minimum 12 characters length - - Must contain uppercase, lowercase, numbers, and special characters - - Password strength validation with entropy scoring - - Password history tracking to prevent reuse of last 12 passwords - - Account lockout after 5 failed attempts with exponential backoff - - Secure password reset with time-limited tokens -- Rate limiting on all endpoints -- PCI DSS compliance for payment data -- Proper input validation and sanitization -- Environment-specific configuration management - -### Background Jobs Architecture -- **Standalone Agent**: Background jobs run as a separate agent/service with no direct database connectivity -- **API-Only Communication**: All data access must go through the Rails API backend via HTTP requests -- **Rails Version**: Background job agent may use Rails 4.2 for sidekiq-web support and compatibility -- **Job Processing**: Sidekiq workers make API calls to the main Rails 8 backend for all data operations -- **Authentication**: Background job API calls use service-to-service authentication tokens -- **Scalability**: This architecture allows independent scaling of job processing and API backend - -### Testing Strategy -- Comprehensive model tests with FactoryBot -- **Password Security Testing**: Comprehensive test coverage for password requirements - - Password complexity validation tests - - Password strength scoring tests - - Account lockout behavior tests - - Password history and reuse prevention tests - - Password reset security flow tests -- API endpoint testing with proper fixtures -- Payment processing tests using VCR or stubs -- Frontend component testing with Testing Library -- E2E tests for critical user flows - -## Development Phases - -The project follows a structured 6-phase approach: -1. **Backend Foundation** - Rails API setup, authentication, core models -2. **Payment Integration** - Gateway integrations, billing logic, webhooks -3. **Analytics & Reporting** - Business intelligence, KPI calculations -4. **Frontend Development** - React app, customer/admin interfaces -5. **Quality Assurance** - Comprehensive testing suite -6. **DevOps & Production** - CI/CD, monitoring, performance optimization - -## Important Notes - -- This is a greenfield project - no existing codebase yet -- Prioritize security and PCI compliance from the start -- Focus on scalable architecture for subscription growth -- Implement comprehensive error handling and logging -- Plan for multi-tenant capabilities if needed -- Consider regulatory compliance (tax calculations, reporting) - -## Current Project Status - -This is a greenfield subscription management platform with comprehensive planning completed: -- Multi-agent development approach defined in `claude-swarm.yml` -- 17 specialized agents for different aspects of development -- Structured 6-phase development approach planned -- No code implementation has begun yet - -- Always update TODO.md when tasks are completed or changes to CLAUDE.md are made. \ No newline at end of file +**Powernode** - Subscription lifecycle management platform: +- **Backend**: Rails 8 API (`./server`) - JWT auth, UUIDv7 primary keys +- **Frontend**: React TypeScript (`./frontend`) - Theme-aware, Tailwind CSS +- **Worker**: Sidekiq standalone (`./worker`) - API-only communication +- **Business**: Git submodule (`./extensions/business`) - proprietary features (billing, BaaS, reseller, AI publisher) +- **Database**: PostgreSQL with native UUID schema +- **Payments**: Stripe, PayPal with PCI compliance (business only) + +**Project Status**: See [docs/TODO.md](docs/TODO.md) (auto-generated from shared knowledge — do not edit manually) + +### Core Models +``` +Account → User (many), Subscription (one) +Subscription → Plan, Payments, Invoices +User → Roles, Permissions, Invitations +``` + +--- + +## Specialists + +Use `platform.discover_skills` with a task description to find the right specialist capability. Fallback: [MCP_CONFIGURATION.md](docs/platform/MCP_CONFIGURATION.md). + +--- + +## Quick Reference - Critical Rules + +### Git Rules +- **NEVER** commit unless explicitly requested +- **NEVER** include Claude attribution in commits +- Branch strategy: `develop` → `feature/*` → `release/*` → `master` +- Tag naming: **NO "v" prefix** - use `0.2.0` not `v0.2.0` +- Release branches: `release/0.2.0` (no "v" prefix) +- **Staged commits**: Group changes into logical commits by concern (models, services, controllers, frontend, tests, config) — never one monolithic commit + +### Business Submodule (`./extensions/business`) +- **Separate git repo** at `extensions/business/` — has its own branch, commits, and remote (`git@git.ipnode.net:powernode/powernode-business.git`) +- **Always check both repos**: `git status` in root AND `git -C extensions/business status` — changes in `extensions/business/` are invisible to the parent repo's `git status` +- **Commit order**: Commit inside `extensions/business/` first, then update the submodule pointer in the parent repo +- **Path aliases**: Business frontend uses `@business/` for intra-business imports, `@/` for core shared imports +- **Core mode**: When business submodule is absent, the app runs as single-user self-hosted (all features unlocked, no billing/SaaS) +- **Feature gating**: `Shared::FeatureGateService.business_loaded?` (backend), `__BUSINESS__` build flag (frontend), `businessOnly: true` on nav items + +### Permission-Based Access Control (CRITICAL) +**Frontend MUST use permissions ONLY - NEVER roles for access control** + +```typescript +// ✅ CORRECT +currentUser?.permissions?.includes('users.manage') + +// ❌ FORBIDDEN +currentUser?.roles?.includes('admin') +user.role === 'manager' +``` + +**Backend**: Use `current_user.has_permission?('name')` - NEVER `permissions.include?()` (returns objects) + +### Frontend Patterns +| Pattern | Rule | +|---------|------| +| Colors | Theme classes only: `bg-theme-*`, `text-theme-*` | +| Navigation | Flat structure - no submenus | +| Actions | ALL in PageContainer - none in page content | +| State | Global notifications only - no local success/error | +| Imports | Path aliases for cross-feature: `@/shared/`, `@/features/` | +| Logging | No `console.log` in production — use `import { logger } from '@/shared/utils/logger'` instead | +| Types | No `any` - proper TypeScript types required | + +### Backend Patterns +| Pattern | Rule | +|---------|------| +| Controllers | `Api::V1` namespace, inherit ApplicationController | +| Responses | MANDATORY: `render_success()`, `render_error()` | +| Worker Jobs | Inherit BaseJob, use `execute()` method, API-only | +| Ruby Files | `# frozen_string_literal: true` pragma required | +| Logging | `Rails.logger` - no `puts`/`print` | +| Migrations | `t.references` automatically creates an index — **NEVER** use `add_index` for reference columns. Customize via the declaration itself: `t.references :account, index: { unique: true }` | +| Namespaces | ALL namespaced models MUST use `::` separator in `class_name:` — e.g., `Ai::AgentTeam` not `AiAgentTeam`, `Devops::Pipeline` not `DevopsPipeline`, `BaaS::Tenant` not `BaaSTenant` | +| Seeds | After modifying seeds, run `cd server && rails db:seed` and verify completion | +| Service Restart | After API endpoint changes, restart: `sudo systemctl restart powernode-backend@default` | +| Associations | Always pair `class_name:` with `foreign_key:` — e.g. `belongs_to :provider, class_name: "Ai::Provider", foreign_key: "ai_provider_id"` | +| Foreign Keys | Namespaced FK prefixes: `Ai::` → `ai_` (`ai_agent_id`), `Devops::` → `devops_` (`devops_pipeline_id`), `BaaS::` → `baas_` (`baas_customer_id`). Others: use explicit FK or omit if unambiguous | +| JSON Columns | Always use lambda defaults: `attribute :config, :json, default: -> { {} }` — never `default: {}` | +| Controller Size | Controllers MUST stay under 300 lines — extract query logic to services, serialization to concerns | +| Eager Loading | Always use `.includes()` when iterating associations — never bare `.all` followed by `.map`/`.each` accessing relations | +| Webhook Receivers | Inbound webhooks MUST return 200/202 on processing errors — NEVER 500 (causes provider retry storms) | + +### Design Principles +| Principle | Rule | +|-----------|------| +| Reuse First | `platform.discover_skills` + `platform.search_knowledge` before proposing anything new — never standalone/greenfield when infrastructure exists | +| Quality Gates | Run `cd frontend && npx tsc --noEmit` after TS changes, verify Ruby syntax after .rb changes | +| Verify Seeds | After seed modifications: `cd server && rails db:seed` — watch for association/validation errors | +| Stop & Ask | **HARD RULE**: After 3 failed attempts at the same fix, STOP immediately and ask the user. Do NOT try a 4th approach, do NOT continue iterating, do NOT try workarounds. Present what you tried and ask for guidance | +| Audit Sessions | When asked to audit/review/analyze code, save findings to `docs/` and do NOT implement changes. Audit = report only, unless the user explicitly says to fix | + +--- + +## Service Management + +```bash +# Systemd services (requires initial install: sudo scripts/systemd/powernode-installer.sh install) +sudo systemctl start powernode.target # Start all services +sudo systemctl stop powernode.target # Stop all services +sudo systemctl restart powernode-backend@default # Restart individual service +sudo scripts/systemd/powernode-installer.sh status # Show all service status +journalctl -u powernode-backend@default -f # Tail service logs +``` + +**NEVER** use manual commands (`rails server`, `sidekiq`, `npm start`) + +--- + +## Test Execution + +**RSpec**: +```bash +cd server && bundle exec rspec --format progress # Full suite +cd server && bundle exec rspec spec/path_spec.rb # Single file +``` + +**Frontend tests** - always use CI=true: +```bash +cd frontend && CI=true npm test +``` + +### Multi-Agent Test Rules +- Uses `DatabaseCleaner` with `:deletion` strategy — avoids `TRUNCATE` deadlocks between concurrent processes. +- Do NOT run multiple single-process rspec instances simultaneously on the same database. +- Frontend tests (`CI=true npm test`) and TypeScript checks (`npx tsc --noEmit`) are always safe to run concurrently. + +### Worker Architecture (CRITICAL) +- The **server** (`server/`) is a Rails API — it does **NOT** run Sidekiq +- The **worker** (`worker/`) is a standalone Sidekiq process — it communicates with server via HTTP API only +- **NEVER** create job classes in `server/app/jobs/` — jobs belong in `worker/app/jobs/` +- **NEVER** add Sidekiq gems to `server/Gemfile` +- **NEVER** modify `worker/` files when fixing server issues + +### Test Patterns Reference +| Pattern | Rule | +|---------|------| +| Factories | `spec/factories/` — use existing factories with traits (`:active`, `:paused`, `:archived`). AI factories in `spec/factories/ai/` | +| User Setup | `user_with_permissions('perm.name')` from `permission_test_helpers.rb` — never create users manually | +| Auth Headers | `auth_headers_for(user)` returns `{ Authorization: Bearer ... }` — use in all request specs | +| Response Helpers | `json_response`, `json_response_data`, `expect_success_response(data)`, `expect_error_response(msg, status)` | +| Shared Examples | `include_examples 'requires authentication'`, `'requires permission'`, `'scopes to current account'` — see `spec/support/shared_examples/` | +| AI Matchers | `be_a_valid_ai_response`, `have_execution_status(:status)`, `create_audit_log(:action)` — see `spec/support/ai_matchers.rb` | +| AI Helpers | `ProviderHelpers`, `AgentHelpers`, `WorkflowHelpers`, `SecurityHelpers` — see `spec/support/ai_test_helpers.rb` | +| E2E Pages | Page objects in `e2e/pages/` — always use existing page objects, check `e2e/pages/ai/` for AI features | +| E2E Selectors | `data-testid` first, then `class*="pattern"`, then `getByRole` — add `data-testid` to new components | +| E2E Guards | `page.on('pageerror', () => {})` in beforeEach, `if (await el.count() > 0)` for optional elements | + +--- + +## Key Platform Documentation + +**Query MCP first** — these files are the fallback when MCP returns no results: + +| Topic | MCP Query | File Fallback | +|-------|-----------|---------------| +| MCP Configuration | `platform.discover_skills` | [MCP_CONFIGURATION.md](docs/platform/MCP_CONFIGURATION.md) | +| Permission System | `platform.search_knowledge` query: "permission system" | [PERMISSION_SYSTEM_REFERENCE.md](docs/platform/PERMISSION_SYSTEM_REFERENCE.md) | +| Theme System | `platform.search_knowledge` query: "theme system" | [THEME_SYSTEM_REFERENCE.md](docs/platform/THEME_SYSTEM_REFERENCE.md) | +| API Standards | `platform.search_knowledge` query: "API response standards" | [API_RESPONSE_STANDARDS.md](docs/platform/API_RESPONSE_STANDARDS.md) | +| UUID System | `platform.search_knowledge` query: "UUID system" | [UUID_SYSTEM_IMPLEMENTATION.md](docs/platform/UUID_SYSTEM_IMPLEMENTATION.md) | +| Workflow System | `platform.search_knowledge` query: "workflow system" | [WORKFLOW_SYSTEM_STANDARDS.md](docs/platform/WORKFLOW_SYSTEM_STANDARDS.md) | +| Architecture | `platform.search_knowledge_graph` query: "platform architecture" | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | +| Learnings & Patterns | `platform.query_learnings` | [LEARNINGS.md](docs/platform/knowledge/LEARNINGS.md) | +| Shared Knowledge | `platform.search_knowledge` | [KNOWLEDGE.md](docs/platform/knowledge/KNOWLEDGE.md) | +| Skills Registry | `platform.discover_skills` | [SKILLS.md](docs/platform/knowledge/SKILLS.md) | +| Knowledge Graph | `platform.search_knowledge_graph` | [GRAPH.md](docs/platform/knowledge/GRAPH.md) | + +--- + +## MCP-First Development Workflow + +The Powernode MCP server (`platform.*` tools) is the **primary knowledge source**. File scanning is the fallback. **MCP queries are NOT optional** — they are mandatory protocol steps. + +### SESSION START Protocol (MANDATORY — every session) + +1. Run `platform.knowledge_health` — establish baseline, identify stale/conflicting knowledge +2. Run `platform.learning_metrics` — check active learnings count, recent contributions +3. If stale_count > 0 or conflicts detected, note them for resolution during the session + +### BEFORE EVERY CODE CHANGE (MANDATORY) + +1. **Search existing knowledge** for the area being modified — not optional, not "when convenient": + - `platform.query_learnings` — established patterns, anti-patterns, failure modes for this area + - `platform.search_knowledge` — procedures, code snippets, reference material + - `platform.search_knowledge_graph` — entity relationships, architecture decisions + - `platform.discover_skills` — reusable capabilities matching the task +2. **Apply discovered knowledge** to your implementation approach +3. **Fall back to file scanning** only when MCP returns no relevant results +4. **Feed file-scan discoveries back** into MCP (see "After Every Task") + +### DURING WORK (Active Reinforcement) + +- **When relying on a learning**: Call `platform.reinforce_learning` with its ID immediately — this prevents decay and boosts confidence +- **When using shared knowledge**: Call `platform.rate_knowledge` (4-5 if helpful, 1-2 if outdated) — this feeds quality scores +- **Pattern verification**: Before introducing a new pattern, check `platform.query_learnings` +- **Architecture context**: Before cross-cutting changes, check `platform.search_knowledge_graph` +- **Memory context**: Use `platform.search_memory` to retrieve agent working memory relevant to the current task +- **API context**: Use `platform.get_api_reference` to look up endpoint contracts before writing integration code +- **Conflict resolution**: If you find two conflicting learnings, resolve with `platform.resolve_contradiction` immediately + +### AFTER EVERY TASK (MANDATORY — zero exceptions for non-trivial work) + +Contribute at least one of: + +| When you... | Do this | +|-------------|---------| +| Solved a non-trivial bug | `platform.create_learning` (category: `discovery` or `failure_mode`) | +| Established/confirmed a pattern | `platform.create_learning` (category: `pattern` or `best_practice`) | +| Documented a procedure or guide | `platform.create_knowledge` (content_type: `procedure`) | +| Found entity relationships | `platform.extract_to_knowledge_graph` | +| Implemented a reusable capability | `platform.create_skill` | + +**Self-check**: "Did I create learnings for the critical findings in this task?" If no, do it now. + +### Skip Contributions For +- Trivial fixes (typos, simple renames, formatting) +- Speculative or unverified analysis +- Knowledge that already exists in MCP (always search first) + +### MCP Helper (Claude Code Sessions) + +Claude Code can invoke MCP tools via the Powernode MCP endpoint. The workspace SSE daemon (`/.claude/hooks/workspace-sse-daemon.sh`) manages OAuth tokens and sessions. Helper functions are available via `source .claude/hooks/mcp-helper.sh`: + +```bash +# Get/cache an OAuth token +mcp_token + +# Invoke any platform.* tool +mcp_call "platform.knowledge_health" '{}' +mcp_call "platform.query_learnings" '{"category": "pattern", "query": "memory access"}' +mcp_call "platform.create_learning" '{"title": "...", "content": "...", "category": "discovery"}' +``` + +--- + +## MCP Tool Catalog + +All `platform.*` tools organized by development task. Full parameter docs: [MCP_TOOL_CATALOG.md](docs/platform/MCP_TOOL_CATALOG.md). + +### Discovery & Context (10 tools) +| Tool | Description | +|------|-------------| +| `search_knowledge` | Semantic search across shared knowledge entries | +| `query_learnings` | Query compound learnings by category, status, or text | +| `search_knowledge_graph` | Semantic search over knowledge graph nodes | +| `reason_knowledge_graph` | Multi-hop reasoning across graph relationships | +| `discover_skills` | Find reusable skills matching a task description | +| `get_skill_context` | Get full execution context for a specific skill | +| `search_memory` | Search agent working/shared memory pools | +| `search_documents` | Search RAG document chunks by query | +| `query_knowledge_base` | Query a specific knowledge base with RAG | +| `get_api_reference` | Look up API endpoint contracts and schemas | + +### Knowledge Contribution (7 tools) +| Tool | Description | +|------|-------------| +| `create_learning` | Create a compound learning (categories: `pattern`, `best_practice`, `discovery`, `failure_mode`) | +| `create_knowledge` | Create a shared knowledge entry (content_types: `procedure`, `reference`, `guide`) | +| `update_knowledge` | Update an existing shared knowledge entry | +| `promote_knowledge` | Promote knowledge for cross-team visibility | +| `extract_to_knowledge_graph` | Extract entities and relationships to the knowledge graph | +| `create_skill` | Register a reusable skill with execution context | +| `update_skill` | Update an existing skill definition | + +### Quality & Reinforcement (9 tools) +| Tool | Description | +|------|-------------| +| `verify_learning` | Verify a learning as accurate (boosts confidence) | +| `dispute_learning` | Dispute an inaccurate learning with reason | +| `resolve_contradiction` | Pick a winner between two conflicting learnings | +| `rate_knowledge` | Rate shared knowledge quality (1-5 scale) | +| `reinforce_learning` | Reinforce a learning that was used successfully | +| `knowledge_health` | Cross-system health report (learnings + knowledge + graph) | +| `learning_metrics` | Compound learning statistics and trends | +| `skill_health` | Skill system health and conflict report | +| `skill_metrics` | Skill usage statistics and effectiveness | + +### Agent Management (5 tools) +| Tool | Description | +|------|-------------| +| `create_agent` | Create a new AI agent with provider and model config | +| `list_agents` | List agents (filterable by status, provider) | +| `get_agent` | Get full agent details including trust score | +| `update_agent` | Update agent configuration | +| `execute_agent` | Execute an agent with a prompt and optional tools | + +### Team Management (6 tools) +| Tool | Description | +|------|-------------| +| `create_team` | Create an agent team with composition rules | +| `list_teams` | List teams (filterable by status) | +| `get_team` | Get team details including members and roles | +| `update_team` | Update team configuration | +| `add_team_member` | Add an agent to a team with a role | +| `execute_team` | Execute a team task with orchestration | + +### Workflow Management (5 tools) +| Tool | Description | +|------|-------------| +| `create_workflow` | Create an AI workflow with nodes and edges | +| `list_workflows` | List workflows (filterable by status, trigger type) | +| `get_workflow` | Get workflow details including node graph | +| `update_workflow` | Update workflow definition | +| `execute_workflow` | Trigger a workflow run with input parameters | + +### Knowledge Graph Exploration (7 tools) +| Tool | Description | +|------|-------------| +| `search_knowledge_graph` | Semantic search over graph nodes | +| `reason_knowledge_graph` | Multi-hop reasoning across relationships | +| `get_graph_node` | Get a specific node with its relationships | +| `list_graph_nodes` | List graph nodes (filterable by type, label) | +| `get_graph_neighbors` | Get connected nodes within N hops | +| `graph_statistics` | Graph-wide statistics (node/edge counts, density) | +| `get_subgraph` | Extract a subgraph around a focal node | + +### Memory Management (6 tools) +| Tool | Description | +|------|-------------| +| `write_shared_memory` | Write to a shared memory pool (key-value with TTL) | +| `read_shared_memory` | Read from a shared memory pool by key | +| `search_memory` | Semantic search across memory entries | +| `consolidate_memory` | Trigger memory tier consolidation (STM→LTM) | +| `memory_stats` | Memory usage statistics per tier | +| `list_pools` | List available memory pools for an agent/team | + +### RAG & Documents (7 tools) +| Tool | Description | +|------|-------------| +| `query_knowledge_base` | Query a knowledge base using RAG retrieval | +| `list_knowledge_bases` | List available knowledge bases | +| `create_knowledge_base` | Create a new knowledge base | +| `add_document` | Add a document to a knowledge base | +| `process_document` | Trigger document chunking and embedding | +| `search_documents` | Search document chunks by semantic query | +| `delete_document` | Remove a document from a knowledge base | + +### Content Management (8 tools) +| Tool | Description | +|------|-------------| +| `list_kb_articles` | List knowledge base articles | +| `get_kb_article` | Get article content and metadata | +| `create_kb_article` | Create a new KB article | +| `update_kb_article` | Update an existing KB article | +| `list_pages` | List content pages | +| `get_page` | Get page content and metadata | +| `create_page` | Create a new content page | +| `update_page` | Update an existing content page | + +### Skill Administration (4 tools) +| Tool | Description | +|------|-------------| +| `list_skills` | List skills with pagination and filters | +| `get_skill` | Get full skill definition and execution context | +| `delete_skill` | Remove a skill | +| `toggle_skill` | Enable or disable a skill | + +### AI Autonomy & Safety (16 tools) +| Tool | Description | +|------|-------------| +| `emergency_halt` | Emergency halt ALL AI activity (kill switch) | +| `emergency_resume` | Resume AI activity after emergency halt | +| `kill_switch_status` | Check current kill switch state | +| `create_agent_goal` | Create a goal for an agent (self or managed) | +| `list_agent_goals` | List an agent's goals (introspection) | +| `update_agent_goal` | Update goal progress or status | +| `agent_introspect` | View own execution history, trust score, performance, and budget | +| `propose_feature` | Create a feature suggestion for human review | +| `send_proactive_notification` | Notify users about detected issues or suggestions | +| `discover_claude_sessions` | Find active Claude Code MCP client sessions | +| `request_code_change` | Request code changes via workspace message | +| `create_proposal` | Formally propose a change for human review | +| `escalate` | Structured escalation when stuck or encountering issues | +| `request_feedback` | Request user feedback on completed work | +| `report_issue` | Report a detected platform issue | + +### DevOps & CI/CD (6 tools) +| Tool | Description | +|------|-------------| +| `create_gitea_repository` | Create a Gitea repository (private by default) | +| `update_gitea_repository` | Update Gitea repo settings (visibility, description, archival) | +| `dispatch_to_runner` | Dispatch a job to a Git runner (GitHub/Gitea) | +| `trigger_pipeline` | Trigger a CI/CD pipeline run | +| `list_pipelines` | List pipelines (filterable by status) | +| `get_pipeline_status` | Get pipeline run status and step details | + +### Docker Management (52 tools) +| Tool | Description | +|------|-------------| +| `docker_list_containers` | List all containers on a host with status, image, ports | +| `docker_get_container` | Detailed info on a specific container | +| `docker_create_container` | Create a new container from an image | +| `docker_start_container` | Start a stopped container | +| `docker_stop_container` | Stop a running container (configurable timeout) | +| `docker_restart_container` | Restart a container | +| `docker_remove_container` | Remove a container (force flag available) | +| `docker_container_logs` | Retrieve container logs (tail + since filters) | +| `docker_container_stats` | Live CPU, memory, network I/O stats | +| `docker_container_exec` | Execute a command inside a running container | +| `docker_list_services` | List all Swarm services with replica counts | +| `docker_get_service` | Detailed info on a specific Swarm service | +| `docker_create_service` | Create a service with image, replicas, ports, env | +| `docker_update_service` | Update service config (image, env, constraints) | +| `docker_scale_service` | Scale a service to a specified replica count | +| `docker_rollback_service` | Rollback a service to its previous version | +| `docker_remove_service` | Remove a service and its tasks | +| `docker_service_logs` | Retrieve aggregated logs from all service tasks | +| `docker_service_tasks` | List tasks with status and node placement | +| `docker_list_stacks` | List all stacks with status and service count | +| `docker_get_stack` | Detailed stack info including compose file | +| `docker_deploy_stack` | Deploy or redeploy a stack from Compose YAML | +| `docker_remove_stack` | Remove a stack and all its services | +| `docker_adopt_stack` | Adopt an externally-deployed stack into Powernode | +| `docker_list_clusters` | List all Swarm clusters with connection status | +| `docker_get_cluster` | Detailed cluster info with node/service counts | +| `docker_cluster_health` | Comprehensive health check (nodes, services, alerts) | +| `docker_list_nodes` | List all nodes with role, availability, resources | +| `docker_node_promote` | Promote a worker node to manager | +| `docker_node_demote` | Demote a manager node to worker | +| `docker_node_drain` | Drain a node (stop scheduling, migrate tasks) | +| `docker_node_activate` | Activate a drained node to resume scheduling | +| `docker_list_secrets` | List secrets (metadata only, not values) | +| `docker_create_secret` | Create a new Swarm secret | +| `docker_remove_secret` | Remove a secret | +| `docker_list_configs` | List all Swarm configs | +| `docker_create_config` | Create a new Swarm config | +| `docker_remove_config` | Remove a config | +| `docker_list_hosts` | List all Docker hosts with connection status | +| `docker_get_host` | Detailed host info (OS, resources, Docker version) | +| `docker_sync_host` | Sync containers and images from Docker daemon | +| `docker_test_host` | Test connection to a Docker host | +| `docker_list_images` | List all images with tags, size, creation time | +| `docker_pull_image` | Pull an image from a registry | +| `docker_remove_image` | Remove an image (force flag available) | +| `docker_tag_image` | Tag an image with a new repository and tag | +| `docker_list_networks` | List Swarm networks with driver and scope | +| `docker_create_network` | Create an overlay network | +| `docker_remove_network` | Remove a network | +| `docker_list_volumes` | List Swarm volumes with driver info | +| `docker_create_volume` | Create a volume with configurable driver | +| `docker_remove_volume` | Remove a volume | + +--- + +## Knowledge Quality Lifecycle + +The platform runs automated maintenance (see `worker/config/sidekiq.yml`). Claude Code participates in the quality loop: + +### Automated (background jobs) +| Job | Schedule | Effect | +|-----|----------|--------| +| Compound learning decay | 3:45 AM daily | `importance_score` decays exponentially on stale learnings | +| Memory consolidation | 4:00 AM daily | Promotes STM→long-term (access>=3), deduplicates (similarity>=0.92) | +| Rot detection | 4:00 AM daily | Auto-archives context entries with staleness>=0.9 | +| Trust score decay | 2:00 AM daily | Decays idle agent trust scores | +| Skill lifecycle | 4:15 AM daily / 5 AM weekly / 3 AM monthly | Conflict scan, stale decay, re-embedding, gap detection | +| Shared knowledge maintenance | Daily | Import from learnings, recalculate quality scores, audit stale entries | +| Escalation timeout | Every 15 min | Auto-escalate overdue escalations | +| Goal maintenance | Every 6 hours | Auto-abandon stale goals | +| Intervention policy tuning | Weekly | Analyze approval patterns and suggest policy adjustments | +| Observation pipeline | Every 30 min | Collect sensor data for autonomous agents | +| Observation cleanup | Daily | Delete expired and old processed observations | +| Proposal expiry | Every hour | Expire overdue unreviewed proposals | + +### Manual (Claude Code responsibilities) +| Trigger | Action | Tool | +|---------|--------|------| +| Used a learning successfully | Reinforce it | `platform.reinforce_learning` | +| Used a learning that was wrong | Dispute it | `platform.dispute_learning` | +| Found two conflicting learnings | Resolve the conflict | `platform.resolve_contradiction` | +| Read useful shared knowledge | Rate it 4-5 | `platform.rate_knowledge` | +| Read outdated shared knowledge | Rate it 1-2, create corrected version | `platform.rate_knowledge` + `platform.create_knowledge` | +| Periodic health check | Run diagnostics | `platform.knowledge_health` + `platform.skill_health` | +| Found stale/wrong code patterns | Fix code, then document the fix | Fix → `platform.create_learning` (category: `discovery`) | +| Removed deprecated code | Document removal | `platform.create_learning` (category: `pattern`) | + +### Proactive Maintenance (during sessions) +- **Before starting work**: Run `platform.knowledge_health` if last check was >24h ago +- **When encountering bugs**: Always search `platform.query_learnings` for existing fix — if found, `reinforce_learning`; if not, fix and `create_learning` +- **When removing stale code**: Create a learning documenting what was removed and why +- **When fixing documentation**: Update `platform.update_knowledge` to correct the source entry + +--- + +## Tool Evolution + +All `platform.*` tools are defined in `server/app/services/ai/tools/platform_api_tool_registry.rb`. +When tools are added/modified, run `cd server && rails mcp:generate_tool_catalog` to regenerate `docs/platform/MCP_TOOL_CATALOG.md`. +When MCP knowledge is updated significantly, run `cd server && rails mcp:sync_docs` to regenerate fallback docs in `docs/platform/knowledge/`. +Knowledge sync runs automatically daily at 5:30 AM UTC via `AiKnowledgeDocSyncJob`. + +### Adding a New Tool +1. Create tool class in `server/app/services/ai/tools/` +2. Add action→class mapping to `PlatformApiToolRegistry::TOOLS` +3. Add `action_definitions` with descriptions and parameter schemas +4. Run `rails mcp:generate_tool_catalog` → updates `docs/platform/MCP_TOOL_CATALOG.md` +5. Update relevant CLAUDE.md component file(s) with the new tool +6. Create learning: `platform.create_learning` category: `pattern` documenting the new tool + +### Deprecating a Tool +1. Add deprecation notice to `action_definitions` description +2. Create learning: `platform.create_learning` category: `best_practice` documenting the replacement +3. Remove from CLAUDE.md after migration period + +--- + +## File Organization + +**NEVER save files to project root**. Use: +- `docs/platform/` - Platform architecture +- `docs/backend/` - Backend documentation +- `docs/frontend/` - Frontend documentation +- `docs/testing/` - Testing documentation +- `docs/services/` - Service documentation +- `docs/infrastructure/` - Infrastructure documentation + +--- + +## Automation Scripts + +```bash +# Code quality +./scripts/pre-commit-quality-check.sh # Run all checks +./scripts/fix-hardcoded-colors.sh # Fix theme violations +./scripts/cleanup-all-console-logs.sh # Remove console.log +./scripts/convert-relative-imports.sh # Fix import paths + +# Pattern validation +./scripts/pattern-validation.sh # Full audit +./scripts/quick-pattern-check.sh # Quick check + +# Pre-push validation +./scripts/validate.sh # Run all checks (specs + TS + patterns) +./scripts/validate.sh --skip-tests # Skip RSpec, run TS + patterns only + +# Service management (systemd) +sudo scripts/systemd/powernode-installer.sh install # Install units + configs +sudo scripts/systemd/powernode-installer.sh add-instance backend api2 # Add instance +sudo scripts/systemd/powernode-installer.sh status # Show all services +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..c56461827 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Everett C. Haimes III + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..4be86fdd9 --- /dev/null +++ b/Makefile @@ -0,0 +1,186 @@ +# Powernode Platform Development Makefile + +.PHONY: help setup install test clean build deploy + +# Default target +help: + @echo "Powernode Platform Development Commands" + @echo "======================================" + @echo "" + @echo "Setup Commands:" + @echo " make setup - Full project setup (install dependencies)" + @echo " make install - Install dependencies only" + @echo " make db-setup - Setup databases" + @echo "" + @echo "Development Commands (All servers listen on 0.0.0.0 for external access):" + @echo " make dev - Start all services via systemd" + @echo " make dev-api - Start Rails API server only (0.0.0.0:3000)" + @echo " make dev-frontend - Start React frontend server only (0.0.0.0:3001)" + @echo " make dev-stop - Stop all services" + @echo " make dev-restart - Restart all services" + @echo " make dev-status - Show service status" + @echo "" + @echo "Testing Commands:" + @echo " make test - Run all tests" + @echo " make test-backend - Run backend tests" + @echo " make test-frontend - Run frontend tests" + @echo " make test-e2e - Run end-to-end tests" + @echo " make test-security - Run security tests" + @echo "" + @echo "Quality Commands:" + @echo " make lint - Run all linters" + @echo " make lint-backend - Run backend linters" + @echo " make lint-frontend - Run frontend linters" + @echo " make security - Run security scans" + @echo "" + @echo "Build Commands:" + @echo " make build - Build both applications" + @echo " make build-backend - Build backend application" + @echo " make build-frontend - Build frontend application" + @echo "" + @echo "Utility Commands:" + @echo " make clean - Clean build artifacts" + @echo " make logs - Show development logs" + @echo " make db-reset - Reset development database" + +# Setup commands +setup: install db-setup + @echo "✅ Full setup complete!" + +install: + @echo "📦 Installing dependencies..." + @cd server && bundle install + @cd frontend && npm install + @echo "✅ Dependencies installed!" + +db-setup: + @echo "🗄️ Setting up databases..." + @cd server && bundle exec rails db:create db:migrate db:seed + @echo "✅ Database setup complete!" + +db-reset: + @echo "🗄️ Resetting development database..." + @cd server && bundle exec rails db:drop db:create db:migrate db:seed + @echo "✅ Database reset complete!" + +# Development commands (systemd) +dev: + @echo "🚀 Starting development servers..." + @sudo systemctl start powernode.target + +dev-api: + @echo "🔧 Starting Rails API server..." + @sudo systemctl start powernode-backend@default + +dev-frontend: + @echo "⚛️ Starting React frontend server..." + @sudo systemctl start powernode-frontend@default + +dev-stop: + @echo "🛑 Stopping development servers..." + @sudo systemctl stop powernode.target + +dev-restart: + @echo "🔄 Restarting development servers..." + @sudo systemctl restart powernode.target + +dev-status: + @echo "📊 Development server status..." + @sudo scripts/systemd/powernode-installer.sh status + +# Testing commands +test: test-backend test-frontend + @echo "✅ All tests completed!" + +test-backend: + @echo "🧪 Running backend tests..." + @cd server && bundle exec rspec --format progress + +test-frontend: + @echo "🧪 Running frontend tests..." + @cd frontend && npm test -- --coverage --watchAll=false + +test-e2e: + @echo "🧪 Running end-to-end tests..." + @cd frontend && npm run cypress:run + +test-security: + @echo "🔒 Running security tests..." + @cd server && bundle exec rails test:security + @cd frontend && npm run lint:security + +# Quality commands +lint: lint-backend lint-frontend + @echo "✅ All linting completed!" + +lint-backend: + @echo "🔍 Running backend linters..." + @cd server && bundle exec rubocop + +lint-frontend: + @echo "🔍 Running frontend linters..." + @cd frontend && npm run lint + +security: + @echo "🔒 Running security scans..." + @cd server && bundle exec bundle-audit check --update || true + @cd server && bundle exec brakeman -q || true + @cd frontend && npm audit --audit-level moderate || true + +# Build commands +build: build-backend build-frontend + @echo "✅ Build completed!" + +build-backend: + @echo "🏗️ Building backend application..." + @cd server && bundle install --deployment --without development test + @cd server && bundle exec rails assets:precompile RAILS_ENV=production + +build-frontend: + @echo "🏗️ Building frontend application..." + @cd frontend && npm run build + +# Utility commands +clean: + @echo "🧹 Cleaning build artifacts..." + @rm -rf server/public/assets/ + @rm -rf server/tmp/cache/ + @rm -rf frontend/build/ + @rm -rf frontend/node_modules/.cache/ + @echo "✅ Clean completed!" + +logs: + @echo "📜 Development logs:" + @echo "Backend logs:" + @tail -n 50 server/log/development.log || echo "No backend logs found" + @echo "" + @echo "Frontend logs available in terminal output" + +# CI/CD helper commands +ci-setup: + @echo "🔧 CI/CD Setup..." + @make install + @make db-setup + +ci-test: + @echo "🧪 CI/CD Testing..." + @make test + @make test-security + @make lint + @make security + +ci-build: + @echo "🏗️ CI/CD Build..." + @make build + +ci-deploy-staging: + @echo "🚀 Deploying to staging..." + @echo "Database migrations..." + @cd server && bundle exec rails db:migrate RAILS_ENV=staging + @echo "Deployment would happen here..." + +ci-deploy-production: + @echo "🚀 Deploying to production..." + @echo "Database migrations..." + @cd server && bundle exec rails db:migrate RAILS_ENV=production + @echo "Deployment would happen here..." \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..6c888f167 --- /dev/null +++ b/README.md @@ -0,0 +1,249 @@ +# Powernode Platform + +> **AI orchestration infrastructure with production-grade platform engineering** + +Powernode is a self-hosted platform that gives you full control over AI agents, automated workflows, and the infrastructure they run on. It combines multi-provider LLM routing, knowledge graph reasoning, and agent autonomy with a complete operational foundation — authentication, permissions, real-time communication, DevOps pipelines, and container orchestration — in a single, coherent system. Every component is designed to work together: agents share memory, learn from execution history, and operate within safety guardrails you define. + +### Why Powernode + +- **AI Agent Orchestration** — Deploy agents with trust scoring, autonomy tiers, and 5 team strategies. Kill switch, goal tracking, proposals, escalations, and behavioral fingerprinting keep agents operating within defined boundaries. +- **Multi-Provider LLM Routing** — 10+ providers (Anthropic, OpenAI, Ollama, Azure, Google, Groq, Grok, Mistral, Cohere), 145+ models, cost-optimized selection with per-agent budgets and ROI tracking. +- **Knowledge Infrastructure** — GraphRAG over 1,190+ nodes and 1,670+ edges, 4-tier memory system (working → STM → LTM → shared), compound learning with decay and reinforcement, RAG pipeline with pgvector embeddings and 3-round agentic retrieval. +- **MCP-Native Platform** — 194 platform tools spanning knowledge, memory, skills, autonomy, DevOps, Docker, and content management. Full A2A protocol support for agent-to-agent communication. +- **DevOps Automation** — CI/CD pipelines with 13 step types (including AI-powered), Docker Swarm orchestration, multi-provider Git integration (GitHub, GitLab, Gitea), supply chain security with SBOM generation. +- **Production Foundation** — 543+ granular permissions, 17 WebSocket channels, JWT + OAuth 2.0 authentication, and 20,600+ tests across backend, frontend, and E2E. + +*Built with Rails 8.1.2, React 19.1 TypeScript, Sidekiq 7.2, and PostgreSQL + pgvector.* + +## Key Features + +### Core Platform +- **Authentication & Security** - JWT + OAuth 2.0, 2FA, account lockout, rate limiting, CORS, CSP +- **Permission-Based Access** - 543+ granular permissions across 30+ categories, role-to-permission mapping +- **Real-time Communication** - 17 ActionCable WebSocket channels for live updates, cross-tab sync +- **Modern UI** - React 19.1 with Tailwind CSS v4.1, theme system, 10 feature modules +- **Content Management** - Knowledge base articles, content pages, CMS +- **Analytics** - Customer health scoring, usage tracking, platform telemetry + +### AI & Automation (145 models, 194 MCP tools) +- **AI Agents** - Create, deploy, and manage agents with trust scoring and autonomy tiers +- **Agent Teams** - Multi-agent orchestration (5 strategies: manager_led, consensus, auction, round_robin, priority_based) +- **AI Workflows** - Visual builder with 35+ node types and circuit breakers +- **AI Autonomy** - Kill switch, goals, proposals, escalations, feedback, intervention policies, observations, duty cycle +- **Code Factory** - PRD generation, automated code review, remediation loops +- **Ralph Loops** - Recursive agent learning with 15-round tool calling +- **Model Router** - Cost-optimized provider selection across 10+ providers (Anthropic, OpenAI, Ollama, Azure, Google, Groq, Grok, Mistral, Cohere) +- **MCP Integration** - 194 platform tools for knowledge, memory, skills, RAG, autonomy, Docker, and DevOps +- **A2A Protocol** - Agent-to-Agent communication with agent cards +- **Memory System** - 4-tier architecture (working, STM, LTM, shared) with consolidation +- **Knowledge Graph** - 1,190+ nodes, 1,670+ edges with hybrid search and GraphRAG +- **RAG Pipeline** - Document chunking, pgvector embeddings, agentic retrieval (3-round reformulation) +- **Security Guardrails** - Behavioral fingerprinting, 5 input rails, 7 output rails, quarantine +- **FinOps** - Agent budgets, cost attribution, ROI metrics, optimization logging +- **AI Monitoring** - Execution traces, telemetry events, circuit breakers, performance benchmarks + +### DevOps & Infrastructure (43 models) +- **Git Integration** - GitHub, GitLab, Gitea, Jenkins provider support +- **CI/CD Pipelines** - 13 step types including AI-powered steps, approval gates +- **Container Orchestration** - Docker host management, container templates, sandboxed execution +- **Docker Swarm** - Cluster, node, service, and stack management with deployment tracking +- **Integration Framework** - 5 integration types (GitHub Actions, webhooks, MCP servers, REST API, custom) +- **Supply Chain Security** - SBOM generation, attestations, license compliance +- **Secrets Management** - Vault-backed secrets with rotation tracking + +### Multi-Platform Chat +- **5 Platforms** - WhatsApp, Telegram, Discord, Slack, Mattermost +- **AI-Powered Routing** - Automatic agent assignment with escalation +- **Prompt Injection Protection** - Content sanitization with delimiter wrapping + +### Worker System (220+ jobs, 33 queues) +- **Standalone Sidekiq 7.2** - Fully isolated, API-only communication with backend +- **3 Priority Tiers** - Critical (weight 3), standard (weight 2), background (weight 1) +- **Circuit Breakers** - 600s AI workflows, 120s backend API timeouts +- **54 Scheduled Jobs** - Maintenance, decay, consolidation, health checks, autonomy, trading + +### Extensions (4 modules) + +Extensions are loaded dynamically via `FeatureGateService`. When no extensions are present, Powernode runs in core mode — single-user self-hosted with all platform features unlocked. + +- **Business** (`extensions/business/`) - Billing engine (Stripe/PayPal), BaaS multi-tenancy, reseller system, AI publisher marketplace, predictive analytics +- **Trading** (`extensions/trading/`) - Algorithmic trading with strategies, portfolios, risk monitoring, and evolution +- **Supply Chain** (`extensions/supply-chain/`) - Supply chain management and logistics +- **Marketing** (`extensions/marketing/`) - Campaign management and marketing automation + +## Architecture Overview + +``` +powernode-platform/ +├── server/ - Rails 8.1.2 API (340+ models, 311+ controllers, 634+ services) +│ ├── app/models/ - 10 namespaces (Ai, Devops, Chat, KnowledgeBase, ...) +│ ├── app/services/ - 22+ service namespaces (634+ files) +│ └── app/channels/ - 17 ActionCable channels +├── frontend/ - React 19.1 TypeScript (10 feature modules) +│ └── src/features/ - account, admin, ai, business, content, delegations, +│ developer, devops, missions, privacy +├── worker/ - Sidekiq 7.2 (220+ jobs, 45 services, 4 API clients) +├── extensions/ - 4 extensions (business, trading, supply-chain, marketing) +├── docs/ - 111 documentation files +└── scripts/ - 48 automation scripts +``` + +### Technology Stack + +- **Backend**: Rails 8.1.2 | PostgreSQL | UUIDv7 | JWT + OAuth 2.0 | Redis +- **Frontend**: React 19.1 | TypeScript 5.9 | Vite 7.2 | Tailwind CSS v4.1 | Redux Toolkit + React Query +- **Worker**: Sidekiq 7.2 | Redis | Faraday | Circuit breakers +- **AI/ML**: 10+ providers | MCP Protocol | A2A Protocol | pgvector (HNSW) +- **Testing**: RSpec | Jest 30 | Cypress 15 | 20,600+ tests +- **Database**: 396+ tables | 10 model namespaces | pgvector embeddings + +### Prerequisites +- Ruby 3.2.8 +- Node.js 18+ +- PostgreSQL 15+ (with pgvector extension) +- Redis 7+ + +## Quick Start + +> For detailed setup instructions, see the **[Quick Start Guide](docs/QUICKSTART.md)**. + +```bash +# 1. Install dependencies +cd server && bundle install +cd ../frontend && npm install +cd ../worker && bundle install +cd .. + +# 2. Setup database +cd server && rails db:create db:migrate db:seed +cd .. + +# 3. Install systemd services (one-time) +sudo scripts/systemd/powernode-installer.sh install + +# 4. Start all services +sudo systemctl start powernode.target + +# 5. Check status +sudo scripts/systemd/powernode-installer.sh status +``` + +Services: +- **Frontend**: http://localhost:3001 +- **API**: http://localhost:3000 +- **Worker Web UI**: http://localhost:4567 + +## Documentation + +### Getting Started +- **[Development Guide](docs/DEVELOPMENT.md)** - Architecture, namespaces, setup +- **[Quick Start](docs/QUICKSTART.md)** - Fast setup guide +- **[CLAUDE.md](CLAUDE.md)** - Development patterns and rules +- **[TODO](docs/TODO.md)** - Current status and roadmap (auto-generated from MCP shared knowledge) + +### Backend +- **[Rails Architect](docs/backend/RAILS_ARCHITECT_SPECIALIST.md)** - API architecture (Rails 8.1.2, 10 namespaces) +- **[Data Modeler](docs/backend/DATA_MODELER_SPECIALIST.md)** - Database & ActiveRecord +- **[Database Schema](docs/backend/DATABASE_SCHEMA_REFERENCE.md)** - 396 tables, namespace reference +- **[Service Architecture](docs/backend/BACKEND_SERVICE_ARCHITECTURE.md)** - 634 services, 22 namespaces +- **[Background Jobs](docs/backend/BACKGROUND_JOB_ENGINEER_SPECIALIST.md)** - Job patterns +- **[Payment Integration](docs/backend/PAYMENT_INTEGRATION_SPECIALIST.md)** - Stripe/PayPal + +### Frontend +- **[React Architect](docs/frontend/REACT_ARCHITECT_SPECIALIST.md)** - React 19.1, Vite 7.2, Tailwind v4.1 +- **[State Management](docs/frontend/STATE_MANAGEMENT_GUIDE.md)** - Redux Toolkit + React Query +- **[UI Components](docs/frontend/UI_COMPONENT_DEVELOPER_SPECIALIST.md)** - Design system +- **[Dashboard](docs/frontend/DASHBOARD_SPECIALIST.md)** - Analytics & charts +- **[WebSocket Integration](docs/frontend/WEBSOCKET_INTEGRATION.md)** - Real-time patterns + +### AI Platform +- **[AI Orchestration Guide](docs/platform/AI_ORCHESTRATION_GUIDE.md)** - Complete AI system overview +- **[AI API Reference](docs/platform/AI_ORCHESTRATION_API_REFERENCE.md)** - 73 AI controllers +- **[Code Factory](docs/platform/CODE_FACTORY_GUIDE.md)** - PRD generation, code review +- **[Ralph Loops](docs/platform/RALPH_LOOPS_GUIDE.md)** - Recursive agent learning +- **[Missions](docs/platform/MISSIONS_GUIDE.md)** - Mission pipeline, 12 phases +- **[Model Router](docs/platform/MODEL_ROUTER_GUIDE.md)** - Cost-optimized routing +- **[Agent Autonomy](docs/platform/AGENT_AUTONOMY_GUIDE.md)** - Kill switch, goals, proposals, escalations, feedback, intervention policies +- **[Memory System](docs/platform/MEMORY_SYSTEM_ARCHITECTURE.md)** - 4-tier memory architecture +- **[Security Guardrails](docs/platform/AI_SECURITY_GUARDRAILS.md)** - Behavioral fingerprinting +- **[RAG System](docs/platform/RAG_SYSTEM_GUIDE.md)** - Knowledge bases, hybrid search +- **[Skill Graph](docs/platform/SKILL_GRAPH_REFERENCE.md)** - Skills registry, gap detection +- **[Cost Attribution](docs/platform/COST_ATTRIBUTION_SYSTEM.md)** - FinOps, budgets, ROI +- **[Provider Routing](docs/platform/AI_PROVIDER_ROUTING.md)** - Multi-provider management +- **[AI Operations](docs/platform/AI_ORCHESTRATION_OPERATIONS.md)** - Monitoring, incident runbooks + +### DevOps & Infrastructure +- **[DevOps Platform](docs/platform/DEVOPS_PLATFORM_GUIDE.md)** - 43 models, pipelines, containers, Swarm +- **[Docker Swarm](docs/infrastructure/DOCKER_SWARM_OPERATIONS.md)** - Cluster operations +- **[Docker Deployment](docs/infrastructure/DOCKER_DEPLOYMENT.md)** - Container setup +- **[Configuration](docs/infrastructure/CONFIGURATION_MANAGEMENT.md)** - Env vars, secrets +- **[Scripts Reference](docs/infrastructure/SCRIPTS_REFERENCE.md)** - 48 automation scripts +- **[DevOps Engineer](docs/infrastructure/DEVOPS_ENGINEER_SPECIALIST.md)** - CI/CD specialist + +### Worker +- **[Worker Architecture](docs/worker/WORKER_ARCHITECTURE_OVERVIEW.md)** - Isolation, API clients, circuit breakers +- **[Worker Operations](docs/worker/WORKER_OPERATIONS_GUIDE.md)** - 220+ jobs, 33 queues, scheduling +- **[CI/CD Architecture](docs/worker/CI_CD_ARCHITECTURE.md)** - Pipeline execution +- **[File Processing](docs/worker/FILE_PROCESSING_ARCHITECTURE.md)** - File handling subsystem + +### Platform References +- **[Changelog](docs/CHANGELOG.md)** - Release history +- **[Permission System](docs/platform/PERMISSION_SYSTEM_REFERENCE.md)** - 543+ permissions, 30+ categories +- **[WebSocket Channels](docs/platform/ACTIONCABLE_CHANNELS_REFERENCE.md)** - 17 channels reference +- **[Chat System](docs/platform/CHAT_SYSTEM_ARCHITECTURE.md)** - Multi-platform chat +- **[Content Management](docs/platform/CONTENT_MANAGEMENT_GUIDE.md)** - KB articles, pages, CMS +- **[Theme System](docs/platform/THEME_SYSTEM_REFERENCE.md)** - Tailwind v4.1 theming +- **[API Standards](docs/platform/API_RESPONSE_STANDARDS.md)** - API conventions +- **[UUID System](docs/platform/UUID_SYSTEM_IMPLEMENTATION.md)** - UUIDv7 across 340+ models +- **[MCP Configuration](docs/platform/MCP_CONFIGURATION.md)** - MCP server setup and OAuth +- **[MCP Tool Catalog](docs/platform/MCP_TOOL_CATALOG.md)** - 194 platform tools reference +- **[Workflow System](docs/platform/WORKFLOW_SYSTEM_STANDARDS.md)** - Workflow patterns +- **[Node Executors](docs/backend/NODE_EXECUTOR_REFERENCE.md)** - 35+ workflow node types + +### Security +- **[Security Specialist](docs/infrastructure/SECURITY_SPECIALIST.md)** - Security architecture +- **[Supply Chain Security](docs/platform/SUPPLY_CHAIN_SECURITY.md)** - SBOM, attestations, compliance + +### Testing +- **[Backend Testing](docs/testing/BACKEND_TEST_ENGINEER_SPECIALIST.md)** - RSpec strategies +- **[Frontend Testing](docs/testing/FRONTEND_TEST_ENGINEER_SPECIALIST.md)** - Jest + React Testing Library +- **[E2E Testing](docs/testing/PLAYWRIGHT_E2E_TESTING.md)** - Playwright patterns + +### Business +- **[Business Overview](extensions/business/README.md)** - Billing, BaaS, reseller, AI publisher + +## Contributing + +Powernode follows strict architectural patterns and enforces them through automated tooling. + +### Getting Oriented +1. Read **[CLAUDE.md](CLAUDE.md)** for development guidelines and conventions +2. Check **[docs/TODO.md](docs/TODO.md)** for current priorities (auto-generated from MCP shared knowledge) +3. Review the specialist documentation for your area (see [Documentation](#documentation) above) + +### Branch Strategy +``` +develop → feature/* → release/* → master +``` +- Create feature branches from `develop` +- Release branches follow `release/x.y.z` naming (no "v" prefix) +- Tags use bare semver: `0.2.0`, not `v0.2.0` + +### Before Submitting +```bash +# Backend: run specs +cd server && bundle exec rspec --format progress + +# Frontend: run tests + type check +cd frontend && CI=true npm test +cd frontend && npx tsc --noEmit + +# Full validation (specs + TS + pattern checks) +./scripts/validate.sh +``` + +All tests must pass. Permissions must use the permission system (never role-based checks). Frontend must use theme classes (`bg-theme-*`, `text-theme-*`) — no hardcoded colors. + +## License + +MIT License — see **[LICENSE](LICENSE)** for full text. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 71aef7dfc..000000000 --- a/TODO.md +++ /dev/null @@ -1,328 +0,0 @@ -# Powernode Platform Development TODO - -## Project Overview -Subscription management platform built with Rails 8 API backend and React TypeScript frontend, featuring Stripe/PayPal integration, automated billing, and comprehensive analytics. - -## Development Status: Phase 6 - DevOps & Production - ---- - -## PHASE 1: Backend Foundation -**Goal**: Establish Rails 8 API-only backend with authentication and core models - -### Project Setup -- [✅] Initialize Rails 8 API-only application in `./server` directory -- [✅] Configure PostgreSQL database connection -- [✅] Set up UUIDv7 primary key configuration for all models -- [✅] Configure CORS for frontend integration -- [✅] Set up basic environment configuration (development, test, production) - -### Authentication System -- [✅] Implement JWT authentication system -- [✅] Create User model with secure password handling -- [✅] Build authentication endpoints (login, logout, token refresh) -- [✅] Add password reset functionality (basic structure) -- [ ] **Implement strong password complexity requirements** - - [ ] Add password validation: minimum 12 characters - - [ ] Require uppercase, lowercase, numbers, and special characters - - [ ] Implement password strength scoring with entropy calculation - - [ ] Add password history tracking (prevent reuse of last 12 passwords) - - [ ] Implement account lockout after 5 failed attempts with exponential backoff - - [ ] Enhance password reset with secure time-limited tokens -- [ ] Implement rate limiting on auth endpoints - -### Core Data Models -- [✅] Create Account model (multi-tenant foundation) -- [✅] Create User model with Account association -- [✅] Implement Role model with permissions system -- [✅] Create Permission model and Role-Permission associations -- [ ] Build Invitation model for user invitations -- [ ] Implement AccountDelegation model for cross-account access -- [✅] Create Plan model with features/limits hash storage -- [✅] Build Subscription model with state machine -- [✅] Create Invoice model with line items -- [✅] Implement Payment model with gateway integration fields -- [✅] Build AuditLog model for comprehensive tracking - -### Model Relationships & Business Logic -- [✅] Configure User-Account associations (users belong to accounts) -- [ ] Implement default role assignment from Plan to User on account creation -- [✅] Set up first user as account owner logic -- [ ] Configure Subscription-Plan associations -- [ ] Implement subscription state machine (active, paused, cancelled, etc.) -- [ ] Add audit logging triggers for all model changes - -### API Endpoints (RESTful) -- [ ] Build Authentication controllers (sessions, passwords, tokens) -- [ ] Create Users controller with CRUD operations -- [ ] Implement Accounts controller with tenant scoping -- [ ] Build Roles & Permissions management endpoints -- [ ] Create Invitations controller with email workflow -- [ ] Implement Subscriptions controller with lifecycle management -- [ ] Build Plans controller for subscription plan management -- [ ] Create basic reporting/analytics endpoints - -### Testing Foundation -- [✅] Set up RSpec testing framework -- [✅] Configure FactoryBot for test data generation -- [ ] Create model factories for all core models -- [ ] Write comprehensive model tests (validations, associations, business logic) -- [ ] Implement controller tests for authentication -- [ ] Add integration tests for critical user flows -- [ ] Set up test database and CI preparation - ---- - -## PHASE 2: Payment Integration ✅ COMPLETED -**Goal**: Integrate Stripe/PayPal with comprehensive webhook handling and billing logic - -### Payment Gateway Setup -- [✅] Configure Stripe API integration -- [✅] Set up PayPal SDK integration -- [✅] Implement payment method storage (PCI compliant) -- [✅] Create webhook endpoints for payment events -- [✅] Build payment processing service objects - -### Billing Engine -- [✅] Implement subscription creation with payment method -- [✅] Build proration calculation engine for mid-cycle changes -- [✅] Create automated renewal processing with background jobs -- [✅] Implement dunning management for failed payments -- [✅] Build invoice generation and PDF creation -- [✅] Add payment retry logic with exponential backoff - -### Background Jobs Architecture -- [✅] Set up Sidekiq as standalone agent (Rails 4.2 compatibility) -- [✅] Configure API-only communication between job agent and main backend -- [✅] Implement service-to-service authentication for job API calls -- [✅] Create renewal processing jobs -- [✅] Build payment retry jobs -- [✅] Implement notification sending jobs - -### Webhook Processing -- [✅] Create Stripe webhook handlers (payment success, failure, subscription updates) -- [✅] Implement PayPal webhook handlers -- [✅] Add webhook signature verification -- [✅] Build webhook event logging and replay functionality -- [✅] Create webhook testing and monitoring - ---- - -## PHASE 3: Analytics & Reporting ✅ COMPLETED -**Goal**: Business intelligence with MRR/ARR calculations and customer insights - -### Analytics Engine -- [✅] Implement MRR (Monthly Recurring Revenue) calculations -- [✅] Build ARR (Annual Recurring Revenue) tracking -- [✅] Create churn analysis algorithms -- [✅] Implement customer lifetime value (CLV) calculations -- [✅] Build cohort analysis functionality - -### Reporting System -- [✅] Create revenue reporting endpoints -- [✅] Implement subscription analytics APIs -- [✅] Build customer metrics dashboards -- [✅] Add payment analytics and success rates -- [✅] Create dunning management reports - -### Data Export -- [✅] Implement CSV export functionality -- [ ] Build PDF report generation -- [✅] Create scheduled report delivery -- [✅] Add data visualization API endpoints - ---- - -## PHASE 4: Frontend Development ✅ COMPLETED -**Goal**: React TypeScript application with customer and admin interfaces - -### Project Setup -- [✅] Initialize React TypeScript application in `./frontend` directory -- [✅] Configure build tools and development environment -- [✅] Set up routing with React Router -- [✅] Configure state management (Redux/Context) -- [✅] Set up API integration layer - -### Authentication Frontend -- [✅] Build login/logout components -- [✅] Create password reset flow -- [✅] Implement protected routes -- [✅] Add JWT token management -- [ ] Build user profile management - -### Customer Dashboard -- [✅] Create main dashboard with subscription overview -- [ ] Build billing history and invoice viewing -- [ ] Implement payment method management -- [ ] Add subscription upgrade/downgrade flows -- [ ] Create usage metrics and analytics views - -### Admin Panel -- [ ] Build comprehensive admin dashboard -- [ ] Create user management interface -- [ ] Implement subscription management tools -- [ ] Add payment processing oversight -- [ ] Build reporting and analytics interfaces - -### Application Settings -- [ ] Create settings management interface -- [ ] Implement user preferences -- [ ] Build account configuration tools -- [ ] Add invitation management system -- [ ] Create delegation management interface - ---- - -## PHASE 5: Quality Assurance ✅ COMPLETED -**Goal**: Comprehensive testing suite and quality assurance - -### Backend Testing -- [✅] Complete model test coverage (>95%) -- [✅] Comprehensive API endpoint testing -- [✅] Payment processing integration tests -- [✅] Webhook handling tests with mocked services -- [✅] Performance testing for subscription operations -- [ ] **Password security comprehensive testing** - - [ ] Password complexity validation test suite - - [ ] Password strength scoring algorithm tests - - [ ] Account lockout behavior and timing tests - - [ ] Password history tracking and reuse prevention tests - - [ ] Secure password reset flow security tests - -### Frontend Testing -- [✅] Component unit tests with Testing Library -- [✅] Integration tests for critical user flows -- [✅] E2E testing with Cypress -- [ ] Accessibility testing and compliance -- [ ] Cross-browser compatibility testing - -### Security Testing -- [✅] Authentication and authorization testing -- [✅] PCI DSS compliance validation -- [✅] Input validation and SQL injection prevention -- [✅] Rate limiting and DDoS protection testing -- [✅] Security audit of payment handling -- [ ] **Enhanced password security testing** - - [ ] Password entropy and complexity validation tests - - [ ] Brute force attack simulation and lockout tests - - [ ] Password reset token security and expiration tests - - [ ] Password history storage security tests - ---- - -## PHASE 6: DevOps & Production -**Goal**: Production deployment, monitoring, and performance optimization - -### Infrastructure -- [ ] Set up production hosting environment -- [ ] Configure PostgreSQL production database -- [ ] Implement Redis for background jobs and caching -- [ ] Set up SSL certificates and HTTPS -- [ ] Configure CDN for static assets - -### CI/CD Pipeline -- [ ] Create automated testing pipeline -- [ ] Implement deployment automation -- [ ] Set up database migration handling -- [ ] Configure environment-specific deployments -- [ ] Add deployment rollback capabilities - -### Monitoring & Performance -- [ ] Implement application performance monitoring (APM) -- [ ] Set up error tracking and alerting -- [ ] Configure log aggregation and analysis -- [ ] Add database performance monitoring -- [ ] Implement uptime monitoring - -### Security & Compliance -- [ ] Final security audit and penetration testing -- [ ] PCI DSS compliance certification -- [ ] Implement backup and disaster recovery -- [ ] Set up security monitoring and incident response -- [ ] Create compliance documentation - ---- - -## Current Priority Tasks -*Focus on Phase 6 - DevOps & Production with Password Security Enhancement* - -### Immediate Next Steps -- [🔄] Set up CI/CD pipeline -- [ ] **Implement strong password security requirements** - - [ ] Add comprehensive password validation to User model - - [ ] Create password strength scoring service - - [ ] Implement password history tracking - - [ ] Add account lockout mechanism - - [ ] Enhance password reset security - - [ ] Write comprehensive test suite for password security -- [ ] Create Docker containers for backend and frontend -- [ ] Configure production deployment to cloud hosting - -### Development Notes -- **Architecture**: API-only backend with React frontend -- **Database**: PostgreSQL with UUIDv7 primary keys -- **Authentication**: JWT-based API authentication -- **Payments**: Stripe (primary), PayPal (secondary) -- **Background Jobs**: Standalone Sidekiq agent with API-only communication -- **Testing**: RSpec (backend), Jest/Testing Library/Cypress (frontend) - -### Key Considerations -- Maintain PCI DSS compliance throughout development -- Implement comprehensive audit logging from the start -- Focus on scalable architecture for subscription growth -- Plan for multi-tenant capabilities -- Ensure proper error handling and logging at all levels - ---- - -## Status Legend -- `[ ]` PENDING - Task not yet started -- `[🔄]` IN_PROGRESS - Currently working on task -- `[✅]` COMPLETED - Task completed successfully -- `[❌]` BLOCKED - Task blocked by dependency or issue -- `[⚠️]` NEEDS_REVIEW - Task completed but requires review - -## Recent Progress (Phase 1 - Backend Foundation) -**Completed:** -- ✅ Rails 8 API-only application setup with PostgreSQL and UUIDv7 primary keys -- ✅ CORS configuration for frontend integration -- ✅ Account and User models with proper validations and associations -- ✅ Role-based access control (RBAC) with Role, Permission, and UserRole models -- ✅ Database seeding with system roles (Owner, Admin, Member) and 18 permissions -- ✅ RSpec and FactoryBot testing framework setup -- ✅ First user automatically becomes account owner -- ✅ JWT authentication system with access/refresh tokens -- ✅ Authentication controllers (login, registration, password management) -- ✅ Plan model with features/limits and pricing (3 default plans seeded) -- ✅ Subscription model with AASM state machine (8 states, trial support) -- ✅ Complete billing infrastructure (Invoice, Payment, InvoiceLineItem models) -- ✅ AuditLog model for comprehensive activity tracking - -**PHASES COMPLETED:** -- ✅ Phase 1 - Backend Foundation (Rails 8 API with authentication, core models, RBAC) -- ✅ Phase 2 - Payment Integration (Stripe/PayPal, webhooks, billing engine, background jobs) -- ✅ Phase 3 - Analytics & Reporting (MRR/ARR, churn analysis, cohort analytics, CSV export) -- ✅ Phase 4 - Frontend Development (React TypeScript, Redux, authentication, dashboard layout) -- ✅ Phase 5 - Quality Assurance (RSpec, Jest, Cypress, security testing, comprehensive coverage) - -**Currently Working On:** -- 🚀 Phase 6 - DevOps & Production -- 🔒 **Enhanced Password Security Implementation** - -**Ready for Phase 6 - DevOps & Production:** -- CI/CD pipeline setup -- **Strong password complexity requirements with comprehensive validation** -- Production deployment configuration (Docker, cloud hosting) -- Monitoring and performance optimization (APM, logging, alerting) -- Security audit and compliance certification -- Database scaling and backup strategies -- Load testing and performance optimization - -**Project Status:** -- 🏗️ **Full-stack foundation COMPLETE** - Ready for production deployment -- 🧪 **Comprehensive testing suite COMPLETE** - 95%+ coverage across all layers -- 🔒 **Security framework COMPLETE** - Authentication, authorization, input validation -- 📊 **Business intelligence COMPLETE** - MRR/ARR analytics with export capabilities -- 🚀 **Production-ready architecture** - Scalable Rails 8 API + React TypeScript SPA - -Last Updated: 2025-08-08 \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..a2268e2de --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.3.1 \ No newline at end of file diff --git a/agent/.keep b/agent/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/bin/dev b/bin/dev deleted file mode 100755 index c158a21ea..000000000 --- a/bin/dev +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "🚀 Starting Powernode Development Servers" -echo "=========================================" -echo "" - -# Function to get the local IP address -get_local_ip() { - if command -v hostname >/dev/null 2>&1; then - hostname -I | awk '{print $1}' 2>/dev/null || echo "192.168.1.100" - else - echo "192.168.1.100" - fi -} - -LOCAL_IP=$(get_local_ip) - -echo "📍 Network Access Information:" -echo " Backend API: http://$LOCAL_IP:3000" -echo " Frontend: http://$LOCAL_IP:3001" -echo "" -echo "📍 Local Access:" -echo " Backend API: http://localhost:3000" -echo " Frontend: http://localhost:3001" -echo "" -echo "🔧 Starting both servers concurrently..." -echo "" - -# Use concurrently if available, otherwise use background processes -if command -v npx >/dev/null 2>&1 && npm list -g concurrently >/dev/null 2>&1; then - npx concurrently \ - --names "API,WEB" \ - --prefix-colors "blue,green" \ - "cd server && ./bin/dev-server" \ - "cd frontend && ./bin/dev-server" -else - echo "⚠️ Note: Install concurrently globally for better output formatting:" - echo " npm install -g concurrently" - echo "" - - # Start servers in background - cd server && ./bin/dev-server & - SERVER_PID=$! - - cd ../frontend && ./bin/dev-server & - FRONTEND_PID=$! - - # Handle cleanup on exit - cleanup() { - echo "" - echo "🛑 Stopping development servers..." - kill $SERVER_PID $FRONTEND_PID 2>/dev/null || true - exit 0 - } - - trap cleanup SIGINT SIGTERM - - # Wait for background processes - wait $SERVER_PID $FRONTEND_PID -fi \ No newline at end of file diff --git a/bin/dev-api b/bin/dev-api deleted file mode 100755 index fe7c3aff5..000000000 --- a/bin/dev-api +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "🔧 Starting Rails API Development Server" -echo "========================================" - -cd server && ./bin/dev-server \ No newline at end of file diff --git a/bin/dev-frontend b/bin/dev-frontend deleted file mode 100755 index 87277025f..000000000 --- a/bin/dev-frontend +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "⚛️ Starting React Frontend Development Server" -echo "=============================================" - -cd frontend && ./bin/dev-server \ No newline at end of file diff --git a/claude-swarm.yml b/claude-swarm.yml deleted file mode 100644 index 82d02a6e6..000000000 --- a/claude-swarm.yml +++ /dev/null @@ -1,440 +0,0 @@ -# Claude Swarm Configuration for Powernode -# Ruby on Rails 8 API + ReactJS Frontend + Payment Processing - -version: 1 -swarm: - name: "subscription-platform-swarm" - main: "platform_architect" - before: - - "echo 'Starting subscription platform development swarm...'" - after: - - "echo 'Swarm session complete. Check TODO.md for progress.'" - instances: - - platform_architect: - description: "General purpose architect orchestrating the entire subscription platform development with oversight of all components" - directory: . - model: sonnet - vibe: true - connections: - - rails_architect - - data_modeler - - payment_integration_specialist - - billing_engine_developer - - api_developer - - background_job_engineer - - react_architect - - ui_component_developer - - dashboard_specialist - - admin_panel_developer - - backend_test_engineer - - frontend_test_engineer - - devops_engineer - - security_specialist - - performance_optimizer - - notification_engineer - - documentation_specialist - - analytics_engineer - prompt: | - You are the platform architect with full system oversight and coordination responsibilities for Powernode. - - Your role encompasses: - - Overall system architecture and design decisions - - Coordination between frontend and backend development - - Integration strategy for payment systems and billing logic - - Quality assurance and testing coordination - - DevOps and deployment planning - - Security and compliance oversight - - Performance optimization strategies - - You have connections to all specialized instances and can delegate specific tasks while maintaining architectural coherence. - - Key Focus Areas: - - Ensure proper separation of concerns between Rails API and React frontend - - Coordinate payment gateway integrations with billing engine - - Oversee data modeling for subscription business logic - - Guide testing strategy across the full stack - - Plan deployment and monitoring architecture - - Reference TODO.md for overall project status, prioritize tasks across development phases, and coordinate work between all specialized agents. - - Use your connections to delegate specialized work while maintaining overall system coherence and architectural best practices. - - rails_architect: - description: "Rails 8 API architect specializing in setup, configuration, and architectural decisions" - directory: . - model: sonnet - prompt: | - You are a Rails 8 API architect responsible for: - - Setting up Rails 8 API-only applications - - Configuring database connections and migrations - - Designing RESTful API endpoints - - Setting up middleware and security configurations - - Implementing authentication systems (JWT) - - Focus on Rails conventions, security best practices, and scalable architecture. - Reference TODO.md for backend foundation tasks including Rails setup, authentication, and API architecture. - - allowed_tools: - - bash - - read - - write - - edit - - data_modeler: - description: "Database designer and ActiveRecord model specialist for subscription platform data layer" - directory: . - model: sonnet - prompt: | - You are a database architect responsible for: - - Designing database schema and relationships - - Creating ActiveRecord models with validations - - Implementing model associations and scopes - - Handling data migrations and versioning - - Optimizing database queries and indexes - - Focus on subscription business logic: User, Subscription, Plan, Invoice, Payment models. - Reference TODO.md for database schema design, model relationships, and data migration tasks. - - allowed_tools: - - bash - - read - - write - - edit - - payment_integration_specialist: - description: "Payment gateway integration expert handling Stripe, PayPal, and webhook processing" - directory: . - model: sonnet - prompt: | - You are a payment integration specialist responsible for: - - Integrating payment gateways (Stripe, PayPal) - - Handling webhook events and processing - - Implementing payment retry logic - - Managing payment method storage - - Handling refunds and chargebacks - - Focus on PCI compliance, security best practices, and robust webhook handling. - Reference TODO.md for payment gateway integration tasks, webhook processing, and payment security implementation. - - allowed_tools: - - bash - - read - - write - - edit - - billing_engine_developer: - description: "Billing logic specialist implementing subscription lifecycles, proration, and automated renewals" - directory: . - model: sonnet - prompt: | - You are a billing engine developer responsible for: - - Implementing subscription lifecycle management - - Building automated renewal systems - - Handling proration calculations - - Developing dunning and recovery systems - - Creating invoicing and PDF generation - - Focus on complex billing logic, Sidekiq background jobs, and automated retry mechanisms. - Reference TODO.md for subscription lifecycle management, billing automation, proration calculations, and dunning system tasks. - - allowed_tools: - - bash - - read - - write - - edit - - api_developer: - description: "RESTful API developer creating endpoints with proper serialization and error handling" - directory: . - model: sonnet - prompt: | - You are an API developer responsible for: - - Implementing CRUD API endpoints - - Handling API versioning and serialization - - Implementing proper error handling - - Adding API documentation - - Optimizing API performance - - Focus on RESTful design, JSON API serialization, and comprehensive error handling. - Reference TODO.md for API endpoint development, serialization implementation, and API documentation tasks. - - allowed_tools: - - bash - - read - - write - - edit - - background_job_engineer: - description: "Background processing specialist managing Sidekiq jobs, scheduling, and worker management" - directory: . - model: sonnet - prompt: | - You are a background job engineer responsible for: - - Setting up Sidekiq for background processing - - Creating scheduled jobs for renewals - - Implementing job retry and failure handling - - Monitoring job performance and queues - - Handling job prioritization - - Focus on reliable job processing, retry mechanisms, and queue optimization. - Reference TODO.md for background job setup, automated renewal processing, notification delivery, and job monitoring tasks. - - allowed_tools: - - bash - - read - - write - - edit - - react_architect: - description: "React application architect setting up TypeScript structure, routing, and state management" - directory: ./frontend - model: sonnet - prompt: | - You are a React architect responsible for: - - Setting up React application with TypeScript - - Configuring routing and navigation - - Implementing state management (Redux/Context) - - Setting up component architecture - - Handling authentication flow - - Focus on modern React patterns, TypeScript best practices, and scalable architecture. - Reference TODO.md for React application setup, routing configuration, state management, and authentication flow tasks. - - allowed_tools: - - bash - - read - - write - - edit - - ui_component_developer: - description: "UI/UX developer creating reusable React components and responsive interfaces" - directory: ./frontend - model: sonnet - prompt: | - You are a UI component developer responsible for: - - Building reusable React components - - Implementing responsive design - - Creating form components with validation - - Handling user interactions and events - - Implementing accessibility features - - Focus on component reusability, responsive design, and WCAG compliance. - Reference TODO.md for UI component development, form handling, responsive design implementation, and accessibility tasks. - - allowed_tools: - - bash - - read - - write - - edit - - dashboard_specialist: - description: "Analytics dashboard developer creating interactive charts and reporting interfaces" - directory: ./frontend - model: sonnet - prompt: | - You are a dashboard specialist responsible for: - - Implementing analytics dashboards - - Creating interactive charts and graphs - - Building reporting interfaces - - Handling real-time data updates - - Optimizing dashboard performance - - Focus on data visualization, real-time updates, and KPI display. - Reference TODO.md for analytics dashboard creation, chart implementation, KPI calculation display, and real-time data visualization tasks. - - allowed_tools: - - bash - - read - - write - - edit - - admin_panel_developer: - description: "Administrative interface developer building comprehensive system management panels" - directory: ./frontend - model: sonnet - prompt: | - You are an admin panel developer responsible for: - - Creating admin dashboard interfaces - - Implementing customer management tools - - Building subscription administration features - - Creating system configuration panels - - Handling bulk operations and exports - - Focus on complex data management, role-based access, and bulk operations. - Reference TODO.md for admin panel development, user management interfaces, subscription administration, and bulk operation tasks. - - allowed_tools: - - bash - - read - - write - - edit - - backend_test_engineer: - description: "Rails testing specialist creating comprehensive test suites for API and business logic" - directory: . - model: sonnet - prompt: | - You are a backend test engineer responsible for: - - Writing RSpec tests for models and controllers - - Creating integration tests for API endpoints - - Testing payment processing and webhooks - - Implementing test factories and fixtures - - Setting up continuous testing workflows - - Focus on comprehensive test coverage, TDD practices, and robust payment testing. - Reference TODO.md for backend test suite development, model testing, API endpoint testing, and payment processing test tasks. - - allowed_tools: - - bash - - read - - write - - edit - - frontend_test_engineer: - description: "Frontend testing specialist implementing unit, integration, and E2E tests for React application" - directory: ./frontend - model: sonnet - prompt: | - You are a frontend test engineer responsible for: - - Writing Jest unit tests for components - - Creating Testing Library integration tests - - Implementing Cypress E2E tests - - Setting up visual regression testing - - Testing user workflows and interactions - - Focus on comprehensive component testing, user workflow validation, and E2E coverage. - Reference TODO.md for frontend test implementation, component testing, user flow validation, and end-to-end testing tasks. - - allowed_tools: - - bash - - read - - write - - edit - - devops_engineer: - description: "DevOps specialist handling deployment, CI/CD, monitoring, and infrastructure automation" - directory: . - model: sonnet - prompt: | - You are a DevOps engineer responsible for: - - Setting up CI/CD pipelines - - Configuring deployment environments - - Implementing monitoring and logging - - Managing database backups and recovery - - Handling security and compliance - - Focus on containerization, automated deployments, and comprehensive monitoring. - Reference TODO.md for CI/CD pipeline setup, deployment configuration, monitoring implementation, and infrastructure automation tasks. - - allowed_tools: - - bash - - read - - write - - edit - - security_specialist: - description: "Security expert ensuring application security, PCI compliance, and data protection" - directory: . - model: sonnet - prompt: | - You are a security specialist responsible for: - - Implementing security best practices - - Handling PCI DSS compliance requirements - - Setting up rate limiting and API protection - - Conducting security audits and penetration testing - - Managing secrets and environment variables - - Focus on OWASP Top 10, PCI compliance, and payment data security. - Reference TODO.md for security audit implementation, PCI compliance setup, vulnerability assessment, and security hardening tasks. - - allowed_tools: - - bash - - read - - write - - edit - - performance_optimizer: - description: "Performance specialist handling load testing, optimization, and scalability planning" - directory: . - model: sonnet - prompt: | - You are a performance optimizer responsible for: - - Conducting load testing and benchmarking - - Optimizing database queries and indexes - - Implementing caching strategies - - Monitoring application performance - - Planning for scalability and growth - - Focus on load testing, database optimization, and scalable architecture. - Reference TODO.md for performance testing setup, database optimization, caching implementation, and scalability planning tasks. - - allowed_tools: - - bash - - read - - write - - edit - - notification_engineer: - description: "Notification specialist implementing email, SMS, and real-time communication systems" - directory: . - model: sonnet - prompt: | - You are a notification engineer responsible for: - - Setting up email delivery systems - - Creating notification templates - - Implementing real-time notifications - - Handling notification preferences - - Tracking delivery and engagement metrics - - Focus on reliable delivery, template personalization, and real-time updates. - Reference TODO.md for notification system setup, email template creation, real-time notification implementation, and delivery tracking tasks. - - allowed_tools: - - bash - - read - - write - - edit - - documentation_specialist: - description: "Technical documentation expert creating comprehensive guides and API documentation" - directory: . - model: sonnet - prompt: | - You are a documentation specialist responsible for: - - Writing API documentation (OpenAPI/Swagger) - - Creating developer integration guides - - Documenting system architecture - - Maintaining deployment guides - - Creating user manuals and tutorials - - Focus on clear technical writing, comprehensive API docs, and developer-friendly guides. - Reference TODO.md for API documentation creation, system architecture documentation, deployment guide writing, and user manual tasks. - - allowed_tools: - - bash - - read - - write - - edit - - analytics_engineer: - description: "Business intelligence specialist implementing analytics, KPIs, and reporting features" - directory: . - model: sonnet - prompt: | - You are an analytics engineer responsible for: - - Designing analytics data models - - Implementing KPI calculations (MRR, ARR, Churn) - - Creating reporting dashboards - - Building data export functionality - - Setting up automated reporting - - Focus on subscription business metrics, data modeling, and actionable insights. - Reference TODO.md for analytics implementation, MRR/ARR calculation setup, churn analysis development, and business intelligence tasks. - - allowed_tools: - - bash - - read - - write - - edit - diff --git a/configs/logging/loki-config.yml b/configs/logging/loki-config.yml new file mode 100644 index 000000000..2f7216708 --- /dev/null +++ b/configs/logging/loki-config.yml @@ -0,0 +1,71 @@ +# Loki configuration for Powernode Platform +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /tmp/loki + storage: + filesystem: + chunks_directory: /tmp/loki/chunks + rules_directory: /tmp/loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +ruler: + alertmanager_url: http://alertmanager:9093 + +# Retention policy for logs +table_manager: + retention_deletes_enabled: true + retention_period: 168h # 7 days + +limits_config: + enforce_metric_name: false + reject_old_samples: true + reject_old_samples_max_age: 168h + +# Chunk store config for better performance +chunk_store_config: + max_look_back_period: 0s + +# Compactor for log cleanup +compactor: + working_directory: /tmp/loki/compactor + shared_store: filesystem + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 150 + +# Frontend configuration +frontend: + log_queries_longer_than: 5s + downstream_url: http://127.0.0.1:3100 + compress_responses: true + +# Query scheduler +query_scheduler: + max_outstanding_requests_per_tenant: 256 \ No newline at end of file diff --git a/configs/logging/promtail-config.yml b/configs/logging/promtail-config.yml new file mode 100644 index 000000000..5bbd5a6b1 --- /dev/null +++ b/configs/logging/promtail-config.yml @@ -0,0 +1,159 @@ +# Promtail configuration for Powernode Platform +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + # Docker container logs + - job_name: containers + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: ["logging=promtail"] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_com_docker_swarm_service_name'] + target_label: 'service_name' + - source_labels: ['__meta_docker_container_label_com_docker_stack_namespace'] + target_label: 'stack' + + # Rails application logs + - job_name: rails-logs + static_configs: + - targets: + - localhost + labels: + job: rails + __path__: /var/log/rails/*.log + pipeline_stages: + - regex: + expression: '^(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(?P\w+)\s+--\s+:\s+(?P.*)' + - timestamp: + source: timestamp + format: RFC3339Nano + - labels: + level: + + # Sidekiq worker logs + - job_name: sidekiq-logs + static_configs: + - targets: + - localhost + labels: + job: sidekiq + __path__: /var/log/sidekiq/*.log + pipeline_stages: + - regex: + expression: '^(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(?P\d+)\s+TID-(?P\w+)\s+(?P\w+):\s+(?P.*)' + - timestamp: + source: timestamp + format: RFC3339Nano + - labels: + level: + pid: + + # Nginx access logs + - job_name: nginx-access + static_configs: + - targets: + - localhost + labels: + job: nginx + log_type: access + __path__: /var/log/nginx/access.log + pipeline_stages: + - regex: + expression: '^(?P[\d\.]+)\s+-\s+-\s+\[(?P[^\]]+)\]\s+"(?P\w+)\s+(?P[^"]+)\s+HTTP/[\d\.]+" (?P\d+) (?P\d+)' + - timestamp: + source: time_local + format: '02/Jan/2006:15:04:05 -0700' + - labels: + method: + status: + log_type: 'access' + + # Nginx error logs + - job_name: nginx-error + static_configs: + - targets: + - localhost + labels: + job: nginx + log_type: error + __path__: /var/log/nginx/error.log + pipeline_stages: + - regex: + expression: '^(?P\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) \[(?P\w+)\] (?P.*)' + - timestamp: + source: timestamp + format: '2006/01/02 15:04:05' + - labels: + level: + log_type: 'error' + + # System logs (syslog) + - job_name: syslog + syslog: + listen_address: 0.0.0.0:1514 + idle_timeout: 60s + label_structured_data: yes + labels: + job: "syslog" + relabel_configs: + - source_labels: ['__syslog_message_hostname'] + target_label: 'host' + - source_labels: ['__syslog_message_severity'] + target_label: 'level' + - source_labels: ['__syslog_message_app_name'] + target_label: 'application' + - source_labels: ['__syslog_message_facility'] + target_label: 'facility' + + # PostgreSQL logs + - job_name: postgres-logs + static_configs: + - targets: + - localhost + labels: + job: postgres + __path__: /var/log/postgresql/*.log + pipeline_stages: + - regex: + expression: '^(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ \w+) \[(?P\d+)\] (?P\w+):\s+(?P.*)' + - timestamp: + source: timestamp + format: '2006-01-02 15:04:05.000 MST' + - labels: + level: + pid: + + # Redis logs + - job_name: redis-logs + static_configs: + - targets: + - localhost + labels: + job: redis + __path__: /var/log/redis/*.log + pipeline_stages: + - regex: + expression: '^(?P\d+):(?P\w) (?P\d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2}\.\d+) (?P.) (?P.*)' + - timestamp: + source: timestamp + format: '02 Jan 2006 15:04:05.000' + - labels: + level: + role: + pid: \ No newline at end of file diff --git a/configs/monitoring/alertmanager.yml b/configs/monitoring/alertmanager.yml new file mode 100644 index 000000000..ef2299b49 --- /dev/null +++ b/configs/monitoring/alertmanager.yml @@ -0,0 +1,185 @@ +# AlertManager configuration for Powernode Platform +global: + # SMTP configuration for email alerts + smtp_smarthost: '${SMTP_HOST:-localhost:587}' + smtp_from: '${ALERT_FROM_EMAIL:-alerts@powernode.io}' + smtp_auth_username: '${SMTP_USERNAME:-}' + smtp_auth_password: '${SMTP_PASSWORD:-}' + smtp_require_tls: true + +# Routing rules +route: + group_by: ['alertname', 'cluster', 'service'] + group_wait: 10s + group_interval: 10s + repeat_interval: 12h + receiver: 'default' + routes: + # Critical alerts - immediate notification + - match: + severity: critical + receiver: 'critical-alerts' + group_wait: 0s + repeat_interval: 5m + routes: + # Database alerts + - match: + component: database + receiver: 'database-critical' + # Application alerts + - match: + component: application + receiver: 'application-critical' + + # Warning alerts - less frequent notifications + - match: + severity: warning + receiver: 'warning-alerts' + group_wait: 30s + repeat_interval: 1h + + # Info alerts - daily digest + - match: + severity: info + receiver: 'info-alerts' + group_wait: 5m + repeat_interval: 24h + +# Inhibition rules - prevent alert spam +inhibit_rules: + # Silence warning if critical is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'instance'] + + # Silence individual service alerts if overall health is down + - source_match: + alertname: 'ServiceDown' + target_match_re: + alertname: '(HighErrorRate|SlowResponseTime|HighMemoryUsage)' + equal: ['service'] + +# Notification receivers +receivers: + # Default receiver + - name: 'default' + email_configs: + - to: '${DEFAULT_EMAIL:-admin@powernode.io}' + subject: '[Powernode] {{ .GroupLabels.alertname }} - {{ .Status | title }}' + body: | + {{ range .Alerts }} + **Alert:** {{ .Annotations.summary }} + **Description:** {{ .Annotations.description }} + **Severity:** {{ .Labels.severity }} + **Service:** {{ .Labels.service }} + **Instance:** {{ .Labels.instance }} + **Started:** {{ .StartsAt }} + {{ if .GeneratorURL }}**Source:** {{ .GeneratorURL }}{{ end }} + {{ end }} + + # Critical alerts - multiple channels + - name: 'critical-alerts' + email_configs: + - to: '${CRITICAL_EMAIL:-oncall@powernode.io}' + subject: '🚨 [CRITICAL] Powernode Alert: {{ .GroupLabels.alertname }}' + body: | + **CRITICAL ALERT** + + {{ range .Alerts }} + **Alert:** {{ .Annotations.summary }} + **Description:** {{ .Annotations.description }} + **Service:** {{ .Labels.service }} + **Instance:** {{ .Labels.instance }} + **Started:** {{ .StartsAt }} + **Runbook:** {{ .Annotations.runbook_url }} + {{ end }} + + Please respond immediately. + # Slack configuration (optional) + # slack_configs: + # - api_url: '${SLACK_WEBHOOK_URL}' + # channel: '#alerts' + # title: 'Critical Alert: {{ .GroupLabels.alertname }}' + # text: '{{ range .Alerts }}{{ .Annotations.summary }}{{ end }}' + # send_resolved: true + + # Database critical alerts + - name: 'database-critical' + email_configs: + - to: '${DBA_EMAIL:-dba@powernode.io}' + subject: '🗄️ [DATABASE CRITICAL] {{ .GroupLabels.alertname }}' + body: | + **DATABASE CRITICAL ALERT** + + {{ range .Alerts }} + **Alert:** {{ .Annotations.summary }} + **Description:** {{ .Annotations.description }} + **Database:** {{ .Labels.database }} + **Instance:** {{ .Labels.instance }} + **Started:** {{ .StartsAt }} + + **Immediate Actions Required:** + 1. Check database connectivity + 2. Verify storage space + 3. Review recent schema changes + 4. Check backup status + {{ end }} + + # Application critical alerts + - name: 'application-critical' + email_configs: + - to: '${DEV_EMAIL:-dev@powernode.io}' + subject: '⚠️ [APPLICATION CRITICAL] {{ .GroupLabels.alertname }}' + body: | + **APPLICATION CRITICAL ALERT** + + {{ range .Alerts }} + **Alert:** {{ .Annotations.summary }} + **Description:** {{ .Annotations.description }} + **Service:** {{ .Labels.service }} + **Instance:** {{ .Labels.instance }} + **Started:** {{ .StartsAt }} + + **Quick Debug Steps:** + 1. Check service logs: docker service logs {{ .Labels.service }} + 2. Check service status: docker service ps {{ .Labels.service }} + 3. Verify health endpoints + 4. Check recent deployments + {{ end }} + + # Warning alerts + - name: 'warning-alerts' + email_configs: + - to: '${WARNING_EMAIL:-ops@powernode.io}' + subject: '[WARNING] Powernode: {{ .GroupLabels.alertname }}' + body: | + **Warning Alert** + + {{ range .Alerts }} + **Alert:** {{ .Annotations.summary }} + **Description:** {{ .Annotations.description }} + **Service:** {{ .Labels.service }} + **Instance:** {{ .Labels.instance }} + **Started:** {{ .StartsAt }} + {{ end }} + + # Info alerts - daily digest + - name: 'info-alerts' + email_configs: + - to: '${INFO_EMAIL:-logs@powernode.io}' + subject: '[INFO] Daily Powernode Report - {{ .GroupLabels.alertname }}' + body: | + **Daily Information Report** + + {{ range .Alerts }} + **Event:** {{ .Annotations.summary }} + **Details:** {{ .Annotations.description }} + **Service:** {{ .Labels.service }} + **Time:** {{ .StartsAt }} + {{ end }} + +# Template definitions +templates: + - '/etc/alertmanager/templates/*.tmpl' \ No newline at end of file diff --git a/configs/monitoring/blackbox.yml b/configs/monitoring/blackbox.yml new file mode 100644 index 000000000..e3b538bf2 --- /dev/null +++ b/configs/monitoring/blackbox.yml @@ -0,0 +1,114 @@ +modules: + http_2xx: + prober: http + timeout: 5s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + valid_status_codes: [200] + method: GET + follow_redirects: true + fail_if_ssl: false + fail_if_not_ssl: false + tls_config: + insecure_skip_verify: false + + http_2xx_ssl: + prober: http + timeout: 10s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + valid_status_codes: [200] + method: GET + follow_redirects: true + fail_if_ssl: false + fail_if_not_ssl: true + tls_config: + insecure_skip_verify: false + + http_api_health: + prober: http + timeout: 5s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + valid_status_codes: [200] + method: GET + headers: + Accept: application/json + fail_if_body_not_matches_regexp: + - '"status":\s*"(ok|healthy)"' + follow_redirects: false + tls_config: + insecure_skip_verify: false + + http_post: + prober: http + timeout: 5s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + valid_status_codes: [200, 201, 202, 204] + method: POST + headers: + Content-Type: application/json + follow_redirects: false + + tcp_connect: + prober: tcp + timeout: 5s + tcp: + preferred_ip_protocol: ip4 + + dns_resolve: + prober: dns + timeout: 5s + dns: + query_name: "example.com" + query_type: "A" + valid_rcodes: + - NOERROR + + icmp_ping: + prober: icmp + timeout: 5s + icmp: + preferred_ip_protocol: ip4 + + # Powernode-specific probes + powernode_backend_health: + prober: http + timeout: 10s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + valid_status_codes: [200] + method: GET + headers: + Accept: application/json + fail_if_body_not_matches_regexp: + - '"status":\s*"healthy"' + - '"database":\s*"connected"' + follow_redirects: false + tls_config: + insecure_skip_verify: false + + powernode_worker_health: + prober: http + timeout: 10s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + valid_status_codes: [200] + method: GET + headers: + Accept: application/json + fail_if_body_not_matches_regexp: + - '"status":\s*"ok"' + follow_redirects: false + + ssl_expiry: + prober: http + timeout: 10s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + method: HEAD + fail_if_ssl: false + fail_if_not_ssl: true + tls_config: + insecure_skip_verify: false diff --git a/configs/monitoring/grafana-dashboards.yml b/configs/monitoring/grafana-dashboards.yml new file mode 100644 index 000000000..c444f514a --- /dev/null +++ b/configs/monitoring/grafana-dashboards.yml @@ -0,0 +1,25 @@ +# Grafana dashboard provisioning configuration +apiVersion: 1 + +providers: + # Dashboard provider for Powernode dashboards + - name: 'powernode-dashboards' + orgId: 1 + folder: 'Powernode' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/dashboards + + # System dashboards + - name: 'system-dashboards' + orgId: 1 + folder: 'System' + type: file + disableDeletion: false + updateIntervalSeconds: 60 + allowUiUpdates: true + options: + path: /etc/grafana/dashboards/system \ No newline at end of file diff --git a/configs/monitoring/grafana-dashboards/powernode-overview.json b/configs/monitoring/grafana-dashboards/powernode-overview.json new file mode 100644 index 000000000..dd1881aa8 --- /dev/null +++ b/configs/monitoring/grafana-dashboards/powernode-overview.json @@ -0,0 +1,736 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + }, + { + "datasource": "Prometheus", + "enable": true, + "expr": "changes(up{job=~\"powernode-.*\"}[5m]) > 0", + "iconColor": "red", + "name": "Service Restarts", + "titleFormat": "Service {{job}} restarted" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "Powernode Platform Overview", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "rate(http_requests_total{job=\"powernode-backend\"}[5m])", + "format": "time_series", + "interval": "", + "legendFormat": "Backend RPS", + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 2 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{job=\"powernode-backend\"}[5m]))", + "format": "time_series", + "interval": "", + "legendFormat": "95th Percentile", + "refId": "A" + } + ], + "title": "Response Time (95th percentile)", + "type": "stat" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 4, + "panels": [], + "title": "Service Health", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "displayMode": "list", + "filterable": false + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "index": 1, + "text": "Down" + }, + "1": { + "color": "green", + "index": 0, + "text": "Up" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Service" + }, + "properties": [ + { + "id": "custom.width", + "value": 200 + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 5, + "options": { + "showHeader": true + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "up{job=~\"powernode-.*\"}", + "format": "table", + "instant": true, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Service Status", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "__name__": true, + "Time": true + }, + "indexByName": { + "Value": 2, + "instance": 1, + "job": 0 + }, + "renameByName": { + "Value": "Status", + "instance": "Instance", + "job": "Service" + } + } + } + ], + "type": "table" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 6, + "panels": [], + "title": "Resource Usage", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "(container_memory_usage_bytes{name=~\"powernode.*\"} / container_spec_memory_limit_bytes{name=~\"powernode.*\"}) * 100", + "format": "time_series", + "interval": "", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Memory Usage", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "rate(container_cpu_usage_seconds_total{name=~\"powernode.*\"}[5m]) * 100", + "format": "time_series", + "interval": "", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "CPU Usage", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 9, + "panels": [], + "title": "Database & Cache", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "pg_stat_database_numbackends", + "format": "time_series", + "interval": "", + "legendFormat": "Active Connections", + "refId": "A" + }, + { + "expr": "pg_settings_max_connections", + "format": "time_series", + "interval": "", + "legendFormat": "Max Connections", + "refId": "B" + } + ], + "title": "PostgreSQL Connections", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "redis_memory_used_bytes", + "format": "time_series", + "interval": "", + "legendFormat": "Used Memory", + "refId": "A" + }, + { + "expr": "redis_config_maxmemory", + "format": "time_series", + "interval": "", + "legendFormat": "Max Memory", + "refId": "B" + } + ], + "title": "Redis Memory Usage", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 27, + "style": "dark", + "tags": [ + "powernode", + "overview" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "production", + "value": "production" + }, + "hide": 0, + "includeAll": false, + "label": "Environment", + "multi": false, + "name": "environment", + "options": [ + { + "selected": true, + "text": "production", + "value": "production" + }, + { + "selected": false, + "text": "staging", + "value": "staging" + } + ], + "query": "production,staging", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "UTC", + "title": "Powernode Platform Overview", + "uid": "powernode-overview", + "version": 1 +} \ No newline at end of file diff --git a/configs/monitoring/grafana-datasources.yml b/configs/monitoring/grafana-datasources.yml new file mode 100644 index 000000000..5527d420f --- /dev/null +++ b/configs/monitoring/grafana-datasources.yml @@ -0,0 +1,46 @@ +# Grafana datasource configuration +apiVersion: 1 + +# List of datasources to configure +datasources: + # Prometheus datasource + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + version: 1 + editable: true + jsonData: + httpMethod: POST + queryTimeout: 60s + timeInterval: 30s + # Custom query parameters + customQueryParameters: '' + # HTTP headers + httpHeaderName1: 'Authorization' + secureJsonData: + httpHeaderValue1: 'Bearer ${PROMETHEUS_TOKEN:-}' + + # Optional: Long-term storage datasource + # - name: Prometheus-LTS + # type: prometheus + # access: proxy + # url: ${PROMETHEUS_LTS_URL:-http://prometheus-lts:9090} + # isDefault: false + # version: 1 + # editable: true + # jsonData: + # timeInterval: 60s + # queryTimeout: 120s + + # Optional: Loki for logs + # - name: Loki + # type: loki + # access: proxy + # url: ${LOKI_URL:-http://loki:3100} + # isDefault: false + # version: 1 + # editable: true + # jsonData: + # maxLines: 1000 \ No newline at end of file diff --git a/configs/monitoring/prometheus.yml b/configs/monitoring/prometheus.yml new file mode 100644 index 000000000..475d20423 --- /dev/null +++ b/configs/monitoring/prometheus.yml @@ -0,0 +1,209 @@ +# Prometheus configuration for Powernode Platform monitoring +global: + scrape_interval: 30s + evaluation_interval: 30s + external_labels: + cluster: 'powernode' + environment: '${ENVIRONMENT:-production}' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 + +# Load alerting rules +rule_files: + - "/etc/prometheus/rules/*.yml" + +# Scrape configurations +scrape_configs: + # Prometheus self-monitoring + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + scrape_interval: 15s + metrics_path: /metrics + + # Docker Swarm service discovery + - job_name: 'dockerswarm' + dockerswarm_sd_configs: + - host: unix:///var/run/docker.sock + role: services + port: 9090 + relabel_configs: + # Only scrape services with prometheus label + - source_labels: [__meta_dockerswarm_service_label_prometheus_scrape] + regex: 'true' + action: keep + # Use custom metrics path if specified + - source_labels: [__meta_dockerswarm_service_label_prometheus_path] + target_label: __metrics_path__ + regex: (.+) + # Use custom port if specified + - source_labels: [__meta_dockerswarm_service_label_prometheus_port] + target_label: __address__ + regex: (.+) + replacement: '${1}' + # Set job name from service name + - source_labels: [__meta_dockerswarm_service_name] + target_label: job + # Add service labels + - source_labels: [__meta_dockerswarm_service_label_prometheus_job] + target_label: job + regex: (.+) + - source_labels: [__meta_dockerswarm_service_name] + target_label: service_name + - source_labels: [__meta_dockerswarm_service_id] + target_label: service_id + + # Node Exporter - System metrics + - job_name: 'node-exporter' + dockerswarm_sd_configs: + - host: unix:///var/run/docker.sock + role: services + port: 9100 + relabel_configs: + - source_labels: [__meta_dockerswarm_service_name] + regex: '.*node.*exporter.*' + action: keep + - source_labels: [__meta_dockerswarm_service_name] + target_label: job + replacement: 'node-exporter' + + # cAdvisor - Container metrics + - job_name: 'cadvisor' + dockerswarm_sd_configs: + - host: unix:///var/run/docker.sock + role: services + port: 8080 + relabel_configs: + - source_labels: [__meta_dockerswarm_service_name] + regex: '.*cadvisor.*' + action: keep + - source_labels: [__meta_dockerswarm_service_name] + target_label: job + replacement: 'cadvisor' + + # Powernode Backend API + - job_name: 'powernode-backend' + dockerswarm_sd_configs: + - host: unix:///var/run/docker.sock + role: services + port: 3000 + relabel_configs: + - source_labels: [__meta_dockerswarm_service_name] + regex: '.*backend.*' + action: keep + - source_labels: [__meta_dockerswarm_service_name] + target_label: service_name + - target_label: __metrics_path__ + replacement: '/api/v1/metrics' + - target_label: job + replacement: 'powernode-backend' + + # Powernode Worker + - job_name: 'powernode-worker' + dockerswarm_sd_configs: + - host: unix:///var/run/docker.sock + role: services + port: 4567 + relabel_configs: + - source_labels: [__meta_dockerswarm_service_name] + regex: '.*worker.*' + action: keep + - source_labels: [__meta_dockerswarm_service_name] + target_label: service_name + - target_label: __metrics_path__ + replacement: '/metrics' + - target_label: job + replacement: 'powernode-worker' + + # Redis Exporter + - job_name: 'redis-exporter' + dockerswarm_sd_configs: + - host: unix:///var/run/docker.sock + role: services + port: 9121 + relabel_configs: + - source_labels: [__meta_dockerswarm_service_name] + regex: '.*redis.*exporter.*' + action: keep + - target_label: job + replacement: 'redis' + + # PostgreSQL Exporter + - job_name: 'postgres-exporter' + dockerswarm_sd_configs: + - host: unix:///var/run/docker.sock + role: services + port: 9187 + relabel_configs: + - source_labels: [__meta_dockerswarm_service_name] + regex: '.*postgres.*exporter.*' + action: keep + - target_label: job + replacement: 'postgresql' + + # Blackbox Exporter - HTTP probes + - job_name: 'blackbox-http' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - ${FRONTEND_URL:-http://frontend}/health + - ${BACKEND_URL:-http://backend:3000}/api/v1/health + - ${WORKER_URL:-http://worker:4567}/health + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox_exporter:9115 + + # Blackbox Exporter - HTTPS probes + - job_name: 'blackbox-https' + metrics_path: /probe + params: + module: [https_2xx] + static_configs: + - targets: + - ${PRODUCTION_URL:-https://powernode.io} + - ${STAGING_URL:-https://staging.powernode.io} + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox_exporter:9115 + + # Grafana monitoring + - job_name: 'grafana' + static_configs: + - targets: ['grafana:3000'] + metrics_path: /metrics + scrape_interval: 30s + + # AlertManager monitoring + - job_name: 'alertmanager' + static_configs: + - targets: ['alertmanager:9093'] + metrics_path: /metrics + scrape_interval: 30s + +# Storage retention +storage: + tsdb: + retention.time: 30d + retention.size: 10GB + +# Remote write configuration (optional - for long-term storage) +# remote_write: +# - url: "https://prometheus-remote-storage.example.com/api/v1/write" +# basic_auth: +# username: username +# password: password \ No newline at end of file diff --git a/configs/monitoring/rules/powernode-alerts.yml b/configs/monitoring/rules/powernode-alerts.yml new file mode 100644 index 000000000..9d9cef0ea --- /dev/null +++ b/configs/monitoring/rules/powernode-alerts.yml @@ -0,0 +1,309 @@ +# Powernode Platform Alert Rules +groups: + - name: powernode.infrastructure + interval: 30s + rules: + # High-level service availability + - alert: ServiceDown + expr: up{job=~"powernode-.*"} == 0 + for: 30s + labels: + severity: critical + component: application + annotations: + summary: "Powernode service {{ $labels.job }} is down" + description: "Service {{ $labels.job }} on instance {{ $labels.instance }} has been down for more than 30 seconds." + runbook_url: "https://runbooks.powernode.io/service-down" + + - alert: HighErrorRate + expr: | + ( + rate(http_requests_total{job="powernode-backend",status=~"5.."}[5m]) / + rate(http_requests_total{job="powernode-backend"}[5m]) + ) * 100 > 5 + for: 5m + labels: + severity: critical + component: application + annotations: + summary: "High error rate detected in Powernode backend" + description: "Error rate is {{ $value }}% for the last 5 minutes, which is above the 5% threshold." + runbook_url: "https://runbooks.powernode.io/high-error-rate" + + - alert: SlowResponseTime + expr: | + histogram_quantile(0.95, + rate(http_request_duration_seconds_bucket{job="powernode-backend"}[5m]) + ) > 2 + for: 5m + labels: + severity: warning + component: application + annotations: + summary: "Slow response time in Powernode backend" + description: "95th percentile response time is {{ $value }}s, which is above the 2s threshold." + runbook_url: "https://runbooks.powernode.io/slow-response" + + - name: powernode.resources + interval: 30s + rules: + # Memory alerts + - alert: HighMemoryUsage + expr: | + ( + (container_memory_usage_bytes{name=~"powernode.*"} / + container_spec_memory_limit_bytes{name=~"powernode.*"}) * 100 + ) > 80 + for: 5m + labels: + severity: warning + component: infrastructure + annotations: + summary: "High memory usage in {{ $labels.name }}" + description: "Memory usage is {{ $value }}% of limit for container {{ $labels.name }}." + runbook_url: "https://runbooks.powernode.io/high-memory" + + - alert: CriticalMemoryUsage + expr: | + ( + (container_memory_usage_bytes{name=~"powernode.*"} / + container_spec_memory_limit_bytes{name=~"powernode.*"}) * 100 + ) > 95 + for: 1m + labels: + severity: critical + component: infrastructure + annotations: + summary: "Critical memory usage in {{ $labels.name }}" + description: "Memory usage is {{ $value }}% of limit for container {{ $labels.name }}. Service may be killed." + runbook_url: "https://runbooks.powernode.io/critical-memory" + + # CPU alerts + - alert: HighCPUUsage + expr: | + ( + rate(container_cpu_usage_seconds_total{name=~"powernode.*"}[5m]) * 100 + ) > 80 + for: 10m + labels: + severity: warning + component: infrastructure + annotations: + summary: "High CPU usage in {{ $labels.name }}" + description: "CPU usage is {{ $value }}% for container {{ $labels.name }} for more than 10 minutes." + + # Disk space alerts + - alert: LowDiskSpace + expr: | + ( + (node_filesystem_avail_bytes{fstype!="tmpfs"} / + node_filesystem_size_bytes{fstype!="tmpfs"}) * 100 + ) < 20 + for: 5m + labels: + severity: warning + component: infrastructure + annotations: + summary: "Low disk space on {{ $labels.instance }}" + description: "Disk space is {{ $value }}% available on {{ $labels.mountpoint }}." + + - alert: CriticalDiskSpace + expr: | + ( + (node_filesystem_avail_bytes{fstype!="tmpfs"} / + node_filesystem_size_bytes{fstype!="tmpfs"}) * 100 + ) < 10 + for: 1m + labels: + severity: critical + component: infrastructure + annotations: + summary: "Critical disk space on {{ $labels.instance }}" + description: "Only {{ $value }}% disk space available on {{ $labels.mountpoint }}." + + - name: powernode.database + interval: 30s + rules: + # PostgreSQL alerts + - alert: PostgreSQLDown + expr: pg_up == 0 + for: 30s + labels: + severity: critical + component: database + annotations: + summary: "PostgreSQL is down" + description: "PostgreSQL database is not responding." + runbook_url: "https://runbooks.powernode.io/postgresql-down" + + - alert: PostgreSQLTooManyConnections + expr: | + ( + pg_stat_database_numbackends / + pg_settings_max_connections * 100 + ) > 80 + for: 5m + labels: + severity: warning + component: database + annotations: + summary: "PostgreSQL has too many connections" + description: "PostgreSQL is using {{ $value }}% of max connections." + + - alert: PostgreSQLSlowQueries + expr: | + rate(pg_stat_database_tup_returned[5m]) / + rate(pg_stat_database_tup_fetched[5m]) < 0.1 + for: 5m + labels: + severity: warning + component: database + annotations: + summary: "PostgreSQL has slow queries" + description: "Query efficiency is {{ $value }}, indicating slow queries." + + - alert: PostgreSQLReplicationLag + expr: | + (pg_stat_replication_pg_wal_lsn_diff > 1000000) + for: 5m + labels: + severity: warning + component: database + annotations: + summary: "PostgreSQL replication lag" + description: "Replication lag is {{ $value }} bytes." + + - name: powernode.redis + interval: 30s + rules: + # Redis alerts + - alert: RedisDown + expr: redis_up == 0 + for: 30s + labels: + severity: critical + component: cache + annotations: + summary: "Redis is down" + description: "Redis cache server is not responding." + runbook_url: "https://runbooks.powernode.io/redis-down" + + - alert: RedisHighMemoryUsage + expr: | + ( + redis_memory_used_bytes / + redis_config_maxmemory * 100 + ) > 80 + for: 5m + labels: + severity: warning + component: cache + annotations: + summary: "Redis high memory usage" + description: "Redis memory usage is {{ $value }}% of max memory." + + - alert: RedisSlowlog + expr: increase(redis_slowlog_length[5m]) > 10 + for: 5m + labels: + severity: warning + component: cache + annotations: + summary: "Redis has slow commands" + description: "Redis slowlog has {{ $value }} new entries in the last 5 minutes." + + - name: powernode.sidekiq + interval: 30s + rules: + # Sidekiq worker alerts + - alert: SidekiqQueueSize + expr: sidekiq_queue_size > 1000 + for: 5m + labels: + severity: warning + component: worker + annotations: + summary: "Sidekiq queue is large" + description: "Sidekiq queue {{ $labels.queue }} has {{ $value }} jobs waiting." + + - alert: SidekiqJobsRetrying + expr: sidekiq_jobs_retry_count > 50 + for: 5m + labels: + severity: warning + component: worker + annotations: + summary: "Many Sidekiq jobs retrying" + description: "{{ $value }} Sidekiq jobs are currently retrying." + + - alert: SidekiqJobsFailing + expr: increase(sidekiq_jobs_failed_total[10m]) > 10 + for: 5m + labels: + severity: warning + component: worker + annotations: + summary: "Sidekiq jobs failing" + description: "{{ $value }} Sidekiq jobs have failed in the last 10 minutes." + + - name: powernode.security + interval: 60s + rules: + # Security alerts + - alert: TooManyFailedLogins + expr: increase(powernode_failed_login_attempts_total[5m]) > 50 + for: 1m + labels: + severity: warning + component: security + annotations: + summary: "Too many failed login attempts" + description: "{{ $value }} failed login attempts in the last 5 minutes." + + - alert: UnauthorizedAPIAccess + expr: increase(powernode_unauthorized_api_requests_total[5m]) > 100 + for: 1m + labels: + severity: warning + component: security + annotations: + summary: "High unauthorized API access" + description: "{{ $value }} unauthorized API requests in the last 5 minutes." + + - alert: SSLCertificateExpiring + expr: (ssl_certificate_expiry_seconds - time()) / 86400 < 30 + for: 1h + labels: + severity: warning + component: security + annotations: + summary: "SSL certificate expiring soon" + description: "SSL certificate for {{ $labels.instance }} expires in {{ $value }} days." + + - name: powernode.business + interval: 300s + rules: + # Business metric alerts + - alert: LowUserRegistrations + expr: increase(powernode_user_registrations_total[1h]) < 5 + for: 2h + labels: + severity: info + component: business + annotations: + summary: "Low user registration rate" + description: "Only {{ $value }} user registrations in the last hour." + + - alert: HighAPIErrorRate + expr: | + ( + increase(powernode_api_errors_total[1h]) / + increase(powernode_api_requests_total[1h]) * 100 + ) > 1 + for: 30m + labels: + severity: warning + component: business + annotations: + summary: "High API error rate affecting users" + description: "API error rate is {{ $value }}% over the last hour." \ No newline at end of file diff --git a/configs/vault/config.hcl b/configs/vault/config.hcl new file mode 100644 index 000000000..81cef8797 --- /dev/null +++ b/configs/vault/config.hcl @@ -0,0 +1,53 @@ +# HashiCorp Vault Server Configuration +# Powernode AI Agent Community Platform + +# Storage backend - Raft for single-node or HA cluster +storage "raft" { + path = "/vault/data" + node_id = "vault-1" + + # Performance tuning + performance_multiplier = 1 +} + +# API listener +listener "tcp" { + address = "0.0.0.0:8200" + cluster_address = "0.0.0.0:8201" + + # TLS Configuration - uncomment for production + # tls_disable = false + # tls_cert_file = "/vault/config/tls/cert.pem" + # tls_key_file = "/vault/config/tls/key.pem" + # tls_min_version = "tls12" + + # For development only - disable in production + tls_disable = true +} + +# API address for clients +api_addr = "http://vault:8200" +cluster_addr = "https://vault:8201" + +# Enable UI +ui = true + +# Telemetry for Prometheus +telemetry { + prometheus_retention_time = "30s" + disable_hostname = true +} + +# Audit logging +# Enable via CLI: vault audit enable file file_path=/vault/logs/audit.log + +# Maximum lease TTL +max_lease_ttl = "768h" +default_lease_ttl = "768h" + +# Disable memory locking if running in container without IPC_LOCK +disable_mlock = false + +# Logging +log_level = "info" +log_format = "json" diff --git a/configs/vault/policies/container-execution.hcl b/configs/vault/policies/container-execution.hcl new file mode 100644 index 000000000..b3ca5c04b --- /dev/null +++ b/configs/vault/policies/container-execution.hcl @@ -0,0 +1,60 @@ +# Vault Policy: Container Execution +# Used by short-lived container tokens during AI agent execution +# +# This policy is highly restricted and scoped to specific account +# using token metadata. Containers can only read secrets for their +# assigned account and execution context. + +# Read only specific account secrets (bound by token metadata) +# The {{identity.entity.metadata.account_id}} template restricts access +# to only the account associated with the container token +path "secret/data/powernode/accounts/{{identity.entity.metadata.account_id}}/ai-providers/*" { + capabilities = ["read"] +} + +path "secret/data/powernode/accounts/{{identity.entity.metadata.account_id}}/mcp-servers/*" { + capabilities = ["read"] +} + +# Read container-specific secrets (temporary, TTL-bounded) +path "secret/data/powernode/containers/{{identity.entity.metadata.execution_id}}/*" { + capabilities = ["read"] +} + +# Explicitly deny access to system secrets +path "secret/data/powernode/system/*" { + capabilities = ["deny"] +} + +# Deny access to other accounts +path "secret/data/powernode/accounts/*" { + capabilities = ["deny"] +} + +# Allow specific paths after general deny +path "secret/data/powernode/accounts/{{identity.entity.metadata.account_id}}/*" { + capabilities = ["read"] +} + +# Deny all token operations except lookup-self +path "auth/token/*" { + capabilities = ["deny"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +# Health check only +path "sys/health" { + capabilities = ["read"] +} + +# No access to sys endpoints +path "sys/*" { + capabilities = ["deny"] +} + +path "sys/health" { + capabilities = ["read"] +} diff --git a/configs/vault/policies/powernode-backend.hcl b/configs/vault/policies/powernode-backend.hcl new file mode 100644 index 000000000..3828f772a --- /dev/null +++ b/configs/vault/policies/powernode-backend.hcl @@ -0,0 +1,54 @@ +# Vault Policy: Powernode Backend Service +# Used by Rails API server for credential management +# +# This policy grants full access to account credentials and +# the ability to generate short-lived tokens for containers. + +# Read system secrets (JWT keys, encryption master key) +path "secret/data/powernode/system/*" { + capabilities = ["read"] +} + +# Full access to account credentials +path "secret/data/powernode/accounts/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# Metadata access for account credentials +path "secret/metadata/powernode/accounts/*" { + capabilities = ["read", "list", "delete"] +} + +# Create short-lived container secrets +path "secret/data/powernode/containers/*" { + capabilities = ["create", "read", "delete"] +} + +# Generate child tokens for container execution +path "auth/token/create/container-execution" { + capabilities = ["create", "update"] +} + +# Revoke tokens (cleanup after container execution) +path "auth/token/revoke-accessor" { + capabilities = ["update"] +} + +# Lookup token info +path "auth/token/lookup-accessor" { + capabilities = ["update"] +} + +# Self token operations +path "auth/token/renew-self" { + capabilities = ["update"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +# Health check +path "sys/health" { + capabilities = ["read"] +} diff --git a/configs/vault/policies/powernode-worker.hcl b/configs/vault/policies/powernode-worker.hcl new file mode 100644 index 000000000..0692307e8 --- /dev/null +++ b/configs/vault/policies/powernode-worker.hcl @@ -0,0 +1,43 @@ +# Vault Policy: Powernode Worker Service +# Used by Sidekiq workers for background job credential access +# +# This policy grants read-only access to credentials needed +# for background processing (email, webhooks, AI tasks, etc.) + +# Read system secrets +path "secret/data/powernode/system/*" { + capabilities = ["read"] +} + +# Read account credentials (needed for AI executions, integrations) +path "secret/data/powernode/accounts/*/ai-providers/*" { + capabilities = ["read"] +} + +path "secret/data/powernode/accounts/*/mcp-servers/*" { + capabilities = ["read"] +} + +path "secret/data/powernode/accounts/*/chat-channels/*" { + capabilities = ["read"] +} + +path "secret/data/powernode/accounts/*/git-credentials/*" { + capabilities = ["read"] +} + +# No write access - workers should not modify credentials + +# Self token operations +path "auth/token/renew-self" { + capabilities = ["update"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +# Health check +path "sys/health" { + capabilities = ["read"] +} diff --git a/docker-compose.mcp.yml b/docker-compose.mcp.yml new file mode 100644 index 000000000..b622c50f6 --- /dev/null +++ b/docker-compose.mcp.yml @@ -0,0 +1,416 @@ +# Docker Compose override for MCP server containers +# Usage: docker compose -f docker-compose.yml -f docker-compose.mcp.yml up +# +# Each MCP server runs in a sandboxed container with: +# - Read-only root filesystem +# - All capabilities dropped +# - No privilege escalation +# - Memory and CPU limits +# - Internal network only (no host port exposure) +# +# Credentials: Copy .env.mcp.example to .env.mcp and fill in your API keys + +services: + # Slack MCP Server + mcp-slack: + image: node:20-slim + container_name: powernode-mcp-slack + command: ["npx", "-y", "@anthropic/mcp-server-slack"] + environment: + - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} + - SLACK_TEAM_ID=${SLACK_TEAM_ID} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Notion MCP Server + mcp-notion: + image: node:20-slim + container_name: powernode-mcp-notion + command: ["npx", "-y", "@notionhq/mcp-server"] + environment: + - NOTION_API_KEY=${NOTION_API_KEY} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Linear MCP Server + mcp-linear: + image: node:20-slim + container_name: powernode-mcp-linear + command: ["npx", "-y", "@anthropic/mcp-server-linear"] + environment: + - LINEAR_API_KEY=${LINEAR_API_KEY} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Figma MCP Server + mcp-figma: + image: node:20-slim + container_name: powernode-mcp-figma + command: ["npx", "-y", "figma-developer/figma-mcp"] + environment: + - FIGMA_ACCESS_TOKEN=${FIGMA_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # HubSpot MCP Server + mcp-hubspot: + image: node:20-slim + container_name: powernode-mcp-hubspot + command: ["npx", "-y", "@anthropic/mcp-server-hubspot"] + environment: + - HUBSPOT_ACCESS_TOKEN=${HUBSPOT_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Atlassian (Jira/Confluence) MCP Server + mcp-atlassian: + image: node:20-slim + container_name: powernode-mcp-atlassian + command: ["npx", "-y", "@anthropic/mcp-server-atlassian"] + environment: + - ATLASSIAN_API_TOKEN=${ATLASSIAN_API_TOKEN} + - ATLASSIAN_URL=${ATLASSIAN_URL} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Asana MCP Server + mcp-asana: + image: node:20-slim + container_name: powernode-mcp-asana + command: ["npx", "-y", "@anthropic/mcp-server-asana"] + environment: + - ASANA_ACCESS_TOKEN=${ASANA_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Intercom MCP Server + mcp-intercom: + image: node:20-slim + container_name: powernode-mcp-intercom + command: ["npx", "-y", "@anthropic/mcp-server-intercom"] + environment: + - INTERCOM_ACCESS_TOKEN=${INTERCOM_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Snowflake MCP Server + mcp-snowflake: + image: node:20-slim + container_name: powernode-mcp-snowflake + command: ["npx", "-y", "@anthropic/mcp-server-snowflake"] + environment: + - SNOWFLAKE_ACCOUNT=${SNOWFLAKE_ACCOUNT} + - SNOWFLAKE_USER=${SNOWFLAKE_USER} + - SNOWFLAKE_PASSWORD=${SNOWFLAKE_PASSWORD} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # BigQuery MCP Server + mcp-bigquery: + image: node:20-slim + container_name: powernode-mcp-bigquery + command: ["npx", "-y", "@anthropic/mcp-server-bigquery"] + environment: + - GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-credentials.json + - NODE_ENV=production + volumes: + - ${GCP_CREDENTIALS_PATH:-./secrets/gcp-credentials.json}:/secrets/gcp-credentials.json:ro + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # MS365 MCP Server + mcp-ms365: + image: node:20-slim + container_name: powernode-mcp-ms365 + command: ["npx", "-y", "@anthropic/mcp-server-microsoft365"] + environment: + - MS365_CLIENT_ID=${MS365_CLIENT_ID} + - MS365_CLIENT_SECRET=${MS365_CLIENT_SECRET} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Box MCP Server + mcp-box: + image: node:20-slim + container_name: powernode-mcp-box + command: ["npx", "-y", "@anthropic/mcp-server-box"] + environment: + - BOX_CLIENT_ID=${BOX_CLIENT_ID} + - BOX_CLIENT_SECRET=${BOX_CLIENT_SECRET} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Amplitude MCP Server + mcp-amplitude: + image: node:20-slim + container_name: powernode-mcp-amplitude + command: ["npx", "-y", "amplitude/mcp-server"] + environment: + - AMPLITUDE_API_KEY=${AMPLITUDE_API_KEY} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Canva MCP Server + mcp-canva: + image: node:20-slim + container_name: powernode-mcp-canva + command: ["npx", "-y", "canva/mcp-server-canva"] + environment: + - CANVA_ACCESS_TOKEN=${CANVA_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Databricks MCP Server (Python-based) + mcp-databricks: + image: python:3.12-slim + container_name: powernode-mcp-databricks + command: ["uvx", "databricks-mcp-server"] + environment: + - DATABRICKS_HOST=${DATABRICKS_HOST} + - DATABRICKS_TOKEN=${DATABRICKS_TOKEN} + - PYTHONUNBUFFERED=1 + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.cache:exec + networks: + - powernode-mcp + profiles: + - mcp + +networks: + powernode-mcp: + driver: bridge + internal: true diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 000000000..bec79a1b6 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,159 @@ +# Docker Compose for production deployment +# Usage: docker-compose -f docker-compose.prod.yml up -d + +services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: powernode-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - powernode-internal + + # Redis for Sidekiq and caching + redis: + image: redis:7-alpine + container_name: powernode-redis + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - powernode-internal + + # Rails API Backend + backend: + build: + context: ./server + dockerfile: Dockerfile + target: production + container_name: powernode-backend + environment: + RAILS_ENV: production + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + JWT_SECRET: ${JWT_SECRET} + WORKER_API_KEY: ${WORKER_API_KEY} + STRIPE_API_KEY: ${STRIPE_API_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID} + PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET} + RAILS_LOG_TO_STDOUT: "true" + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - powernode-internal + - powernode-external + labels: + - "traefik.enable=true" + - "traefik.http.routers.backend.rule=Host(`api.${DOMAIN}`)" + - "traefik.http.routers.backend.tls=true" + - "traefik.http.routers.backend.tls.certresolver=letsencrypt" + - "traefik.http.services.backend.loadbalancer.server.port=3000" + + # Sidekiq Worker + worker: + build: + context: ./worker + dockerfile: Dockerfile + target: production + container_name: powernode-worker + environment: + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 + BACKEND_API_URL: http://backend:3000 + WORKER_API_KEY: ${WORKER_API_KEY} + STRIPE_API_KEY: ${STRIPE_API_KEY} + PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID} + PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET} + restart: unless-stopped + depends_on: + - backend + - redis + networks: + - powernode-internal + + # React Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: https://api.${DOMAIN} + VITE_WS_URL: wss://api.${DOMAIN} + container_name: powernode-frontend + restart: unless-stopped + depends_on: + - backend + networks: + - powernode-external + labels: + - "traefik.enable=true" + - "traefik.http.routers.frontend.rule=Host(`${DOMAIN}`) || Host(`www.${DOMAIN}`)" + - "traefik.http.routers.frontend.tls=true" + - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" + - "traefik.http.services.frontend.loadbalancer.server.port=80" + + # Traefik Reverse Proxy + traefik: + image: traefik:v3.0 + container_name: powernode-traefik + command: + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + restart: unless-stopped + networks: + - powernode-external + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)" + - "traefik.http.routers.traefik.tls=true" + - "traefik.http.routers.traefik.tls.certresolver=letsencrypt" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=traefik-auth" + - "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH}" + +volumes: + postgres_data: + redis_data: + traefik_letsencrypt: + +networks: + powernode-internal: + driver: bridge + powernode-external: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..b5d4a650b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,102 @@ +# Docker Compose for local development +# Usage: docker-compose up -d + +services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: powernode-postgres + environment: + POSTGRES_USER: powernode + POSTGRES_PASSWORD: powernode_dev + POSTGRES_DB: powernode_development + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U powernode"] + interval: 10s + timeout: 5s + retries: 5 + + # Redis for Sidekiq and caching + redis: + image: redis:7-alpine + container_name: powernode-redis + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # Rails API Backend + backend: + build: + context: ./server + dockerfile: Dockerfile.dev + container_name: powernode-backend + environment: + RAILS_ENV: development + DATABASE_URL: postgres://powernode:powernode_dev@postgres:5432/powernode_development + REDIS_URL: redis://redis:6379/0 + SECRET_KEY_BASE: dev_secret_key_base_for_local_development_only + JWT_SECRET: dev_jwt_secret_for_local_development_only + WORKER_API_KEY: dev_worker_api_key + volumes: + - ./server:/app + - backend_gems:/usr/local/bundle + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + stdin_open: true + tty: true + + # Sidekiq Worker + worker: + build: + context: ./worker + dockerfile: Dockerfile.dev + container_name: powernode-worker + environment: + REDIS_URL: redis://redis:6379/0 + BACKEND_API_URL: http://backend:3000 + WORKER_API_KEY: dev_worker_api_key + volumes: + - ./worker:/app + - worker_gems:/usr/local/bundle + depends_on: + - backend + - redis + + # React Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: powernode-frontend + environment: + VITE_API_URL: http://localhost:3000 + VITE_WS_URL: ws://localhost:3000 + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "3001:3001" + depends_on: + - backend + +volumes: + postgres_data: + redis_data: + backend_gems: + worker_gems: diff --git a/docker/docker-compose.mcp.yml b/docker/docker-compose.mcp.yml new file mode 100644 index 000000000..6cb9bda8c --- /dev/null +++ b/docker/docker-compose.mcp.yml @@ -0,0 +1,416 @@ +# Docker Compose override for MCP server containers +# Usage: docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.yml up +# +# Each MCP server runs in a sandboxed container with: +# - Read-only root filesystem +# - All capabilities dropped +# - No privilege escalation +# - Memory and CPU limits +# - Internal network only (no host port exposure) +# +# Credentials: Copy .env.mcp.example to .env.mcp and fill in your API keys + +services: + # Slack MCP Server + mcp-slack: + image: node:20-slim + container_name: powernode-mcp-slack + command: ["npx", "-y", "@anthropic/mcp-server-slack"] + environment: + - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} + - SLACK_TEAM_ID=${SLACK_TEAM_ID} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Notion MCP Server + mcp-notion: + image: node:20-slim + container_name: powernode-mcp-notion + command: ["npx", "-y", "@notionhq/mcp-server"] + environment: + - NOTION_API_KEY=${NOTION_API_KEY} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Linear MCP Server + mcp-linear: + image: node:20-slim + container_name: powernode-mcp-linear + command: ["npx", "-y", "@anthropic/mcp-server-linear"] + environment: + - LINEAR_API_KEY=${LINEAR_API_KEY} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Figma MCP Server + mcp-figma: + image: node:20-slim + container_name: powernode-mcp-figma + command: ["npx", "-y", "figma-developer/figma-mcp"] + environment: + - FIGMA_ACCESS_TOKEN=${FIGMA_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # HubSpot MCP Server + mcp-hubspot: + image: node:20-slim + container_name: powernode-mcp-hubspot + command: ["npx", "-y", "@anthropic/mcp-server-hubspot"] + environment: + - HUBSPOT_ACCESS_TOKEN=${HUBSPOT_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Atlassian (Jira/Confluence) MCP Server + mcp-atlassian: + image: node:20-slim + container_name: powernode-mcp-atlassian + command: ["npx", "-y", "@anthropic/mcp-server-atlassian"] + environment: + - ATLASSIAN_API_TOKEN=${ATLASSIAN_API_TOKEN} + - ATLASSIAN_URL=${ATLASSIAN_URL} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Asana MCP Server + mcp-asana: + image: node:20-slim + container_name: powernode-mcp-asana + command: ["npx", "-y", "@anthropic/mcp-server-asana"] + environment: + - ASANA_ACCESS_TOKEN=${ASANA_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Intercom MCP Server + mcp-intercom: + image: node:20-slim + container_name: powernode-mcp-intercom + command: ["npx", "-y", "@anthropic/mcp-server-intercom"] + environment: + - INTERCOM_ACCESS_TOKEN=${INTERCOM_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Snowflake MCP Server + mcp-snowflake: + image: node:20-slim + container_name: powernode-mcp-snowflake + command: ["npx", "-y", "@anthropic/mcp-server-snowflake"] + environment: + - SNOWFLAKE_ACCOUNT=${SNOWFLAKE_ACCOUNT} + - SNOWFLAKE_USER=${SNOWFLAKE_USER} + - SNOWFLAKE_PASSWORD=${SNOWFLAKE_PASSWORD} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # BigQuery MCP Server + mcp-bigquery: + image: node:20-slim + container_name: powernode-mcp-bigquery + command: ["npx", "-y", "@anthropic/mcp-server-bigquery"] + environment: + - GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-credentials.json + - NODE_ENV=production + volumes: + - ${GCP_CREDENTIALS_PATH:-../secrets/gcp-credentials.json}:/secrets/gcp-credentials.json:ro + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # MS365 MCP Server + mcp-ms365: + image: node:20-slim + container_name: powernode-mcp-ms365 + command: ["npx", "-y", "@anthropic/mcp-server-microsoft365"] + environment: + - MS365_CLIENT_ID=${MS365_CLIENT_ID} + - MS365_CLIENT_SECRET=${MS365_CLIENT_SECRET} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Box MCP Server + mcp-box: + image: node:20-slim + container_name: powernode-mcp-box + command: ["npx", "-y", "@anthropic/mcp-server-box"] + environment: + - BOX_CLIENT_ID=${BOX_CLIENT_ID} + - BOX_CLIENT_SECRET=${BOX_CLIENT_SECRET} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Amplitude MCP Server + mcp-amplitude: + image: node:20-slim + container_name: powernode-mcp-amplitude + command: ["npx", "-y", "amplitude/mcp-server"] + environment: + - AMPLITUDE_API_KEY=${AMPLITUDE_API_KEY} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Canva MCP Server + mcp-canva: + image: node:20-slim + container_name: powernode-mcp-canva + command: ["npx", "-y", "canva/mcp-server-canva"] + environment: + - CANVA_ACCESS_TOKEN=${CANVA_ACCESS_TOKEN} + - NODE_ENV=production + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.npm:exec + networks: + - powernode-mcp + profiles: + - mcp + + # Databricks MCP Server (Python-based) + mcp-databricks: + image: python:3.12-slim + container_name: powernode-mcp-databricks + command: ["uvx", "databricks-mcp-server"] + environment: + - DATABRICKS_HOST=${DATABRICKS_HOST} + - DATABRICKS_TOKEN=${DATABRICKS_TOKEN} + - PYTHONUNBUFFERED=1 + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + tmpfs: + - /tmp + - /root/.cache:exec + networks: + - powernode-mcp + profiles: + - mcp + +networks: + powernode-mcp: + driver: bridge + internal: true diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 000000000..09c02c6d3 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,159 @@ +# Docker Compose for production deployment +# Usage: docker compose -f docker/docker-compose.prod.yml up -d + +services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: powernode-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - powernode-internal + + # Redis for Sidekiq and caching + redis: + image: redis:7-alpine + container_name: powernode-redis + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - powernode-internal + + # Rails API Backend + backend: + build: + context: ../server + dockerfile: Dockerfile + target: production + container_name: powernode-backend + environment: + RAILS_ENV: production + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + JWT_SECRET: ${JWT_SECRET} + WORKER_API_KEY: ${WORKER_API_KEY} + STRIPE_API_KEY: ${STRIPE_API_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID} + PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET} + RAILS_LOG_TO_STDOUT: "true" + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - powernode-internal + - powernode-external + labels: + - "traefik.enable=true" + - "traefik.http.routers.backend.rule=Host(`api.${DOMAIN}`)" + - "traefik.http.routers.backend.tls=true" + - "traefik.http.routers.backend.tls.certresolver=letsencrypt" + - "traefik.http.services.backend.loadbalancer.server.port=3000" + + # Sidekiq Worker + worker: + build: + context: ../worker + dockerfile: Dockerfile + target: production + container_name: powernode-worker + environment: + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 + BACKEND_API_URL: http://backend:3000 + WORKER_API_KEY: ${WORKER_API_KEY} + STRIPE_API_KEY: ${STRIPE_API_KEY} + PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID} + PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET} + restart: unless-stopped + depends_on: + - backend + - redis + networks: + - powernode-internal + + # React Frontend + frontend: + build: + context: ../frontend + dockerfile: Dockerfile + args: + VITE_API_URL: https://api.${DOMAIN} + VITE_WS_URL: wss://api.${DOMAIN} + container_name: powernode-frontend + restart: unless-stopped + depends_on: + - backend + networks: + - powernode-external + labels: + - "traefik.enable=true" + - "traefik.http.routers.frontend.rule=Host(`${DOMAIN}`) || Host(`www.${DOMAIN}`)" + - "traefik.http.routers.frontend.tls=true" + - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" + - "traefik.http.services.frontend.loadbalancer.server.port=80" + + # Traefik Reverse Proxy + traefik: + image: traefik:v3.0 + container_name: powernode-traefik + command: + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + restart: unless-stopped + networks: + - powernode-external + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)" + - "traefik.http.routers.traefik.tls=true" + - "traefik.http.routers.traefik.tls.certresolver=letsencrypt" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=traefik-auth" + - "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH}" + +volumes: + postgres_data: + redis_data: + traefik_letsencrypt: + +networks: + powernode-internal: + driver: bridge + powernode-external: + driver: bridge diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 000000000..8c567c275 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,102 @@ +# Docker Compose for local development +# Usage: docker compose -f docker/docker-compose.yml up -d + +services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: powernode-postgres + environment: + POSTGRES_USER: powernode + POSTGRES_PASSWORD: powernode_dev + POSTGRES_DB: powernode_development + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U powernode"] + interval: 10s + timeout: 5s + retries: 5 + + # Redis for Sidekiq and caching + redis: + image: redis:7-alpine + container_name: powernode-redis + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # Rails API Backend + backend: + build: + context: ../server + dockerfile: Dockerfile.dev + container_name: powernode-backend + environment: + RAILS_ENV: development + DATABASE_URL: postgres://powernode:powernode_dev@postgres:5432/powernode_development + REDIS_URL: redis://redis:6379/0 + SECRET_KEY_BASE: dev_secret_key_base_for_local_development_only + JWT_SECRET: dev_jwt_secret_for_local_development_only + WORKER_API_KEY: dev_worker_api_key + volumes: + - ../server:/app + - backend_gems:/usr/local/bundle + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + stdin_open: true + tty: true + + # Sidekiq Worker + worker: + build: + context: ../worker + dockerfile: Dockerfile.dev + container_name: powernode-worker + environment: + REDIS_URL: redis://redis:6379/0 + BACKEND_API_URL: http://backend:3000 + WORKER_API_KEY: dev_worker_api_key + volumes: + - ../worker:/app + - worker_gems:/usr/local/bundle + depends_on: + - backend + - redis + + # React Frontend + frontend: + build: + context: ../frontend + dockerfile: Dockerfile.dev + container_name: powernode-frontend + environment: + VITE_API_URL: http://localhost:3000 + VITE_WS_URL: ws://localhost:3000 + volumes: + - ../frontend:/app + - /app/node_modules + ports: + - "3001:3001" + depends_on: + - backend + +volumes: + postgres_data: + redis_data: + backend_gems: + worker_gems: diff --git a/docker/swarm/configs/cable.yml b/docker/swarm/configs/cable.yml new file mode 100644 index 000000000..f3f2a6e85 --- /dev/null +++ b/docker/swarm/configs/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: redis + url: redis://redis:6379/1 + +test: + adapter: test + +production: + adapter: redis + url: redis://redis:6379/1 diff --git a/docker/swarm/dev.yml b/docker/swarm/dev.yml new file mode 100644 index 000000000..63548804e --- /dev/null +++ b/docker/swarm/dev.yml @@ -0,0 +1,194 @@ +# Docker Swarm stack for Powernode Platform - Dev Testing +# Deploy: docker stack deploy -c docker/swarm/dev.yml --with-registry-auth powernode-dev +# Teardown: docker stack rm powernode-dev +# +# NOTE: This is a DEV-ONLY configuration. Secrets are plain env vars. +# Do NOT use these values in staging/production. + +services: + # PostgreSQL with pgvector extension (required by schema) + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_USER: powernode + POSTGRES_PASSWORD: pn-dev-db-2026-xK9mQ4 + POSTGRES_DB: powernode_development + volumes: + - postgres_dev_data:/var/lib/postgresql/data + networks: + - backend + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 5 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U powernode -d powernode_development"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + + # Redis (no password - internal overlay network only) + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redis_dev_data:/data + networks: + - backend + deploy: + replicas: 1 + restart_policy: + condition: on-failure + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 15s + timeout: 5s + retries: 3 + + # Rails Backend API + # Runs production image in development mode (seeds create admin user only in dev/test) + backend: + image: git.ipnode.org/powernode/powernode-backend:${TAG:-dev-latest} + environment: + - RAILS_ENV=production + - SEED_ADMIN_USERS=true + - DATABASE_HOST=postgres + - DATABASE_USER=powernode + - POWERNODE_DATABASE_PASSWORD=pn-dev-db-2026-xK9mQ4 + - DATABASE_NAME=powernode_development + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY_BASE=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8 + - RAILS_LOG_TO_STDOUT=1 + - RAILS_SERVE_STATIC_FILES=true + - FORCE_SSL=false + - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=dev-pn-primary-key-2026-xK9mQ4aB + - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=dev-pn-deterministic-key-2026-yL0nR5bC + - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=dev-pn-derivation-salt-2026-zM1oS6cD + - JWT_SECRET_KEY=dev-pn-jwt-secret-2026-long-enough-for-hmac-signing + - JWT_ALGORITHM=HS256 + - DOORKEEPER_ENCRYPTION_METHOD=hs256 + - CACHE_STORE=memory_store + - QUEUE_ADAPTER=async + configs: + - source: dev_cable_config + target: /app/config/cable.yml + networks: + - frontend + - backend + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 10s + max_attempts: 5 + labels: + - traefik.enable=true + # API routing + - traefik.http.routers.dev-api.rule=PathPrefix(`/api`) + - traefik.http.routers.dev-api.entrypoints=web + - traefik.http.routers.dev-api.service=dev-api + - traefik.http.services.dev-api.loadbalancer.server.port=3000 + # ActionCable WebSocket routing + - traefik.http.routers.dev-cable.rule=PathPrefix(`/cable`) + - traefik.http.routers.dev-cable.entrypoints=web + - traefik.http.routers.dev-cable.service=dev-cable + - traefik.http.services.dev-cable.loadbalancer.server.port=3000 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + + # Sidekiq Worker (standalone, communicates with backend via HTTP API) + worker: + image: git.ipnode.org/powernode/powernode-worker:${TAG:-dev-latest} + environment: + - REDIS_URL=redis://redis:6379/1 + - BACKEND_API_URL=http://backend:3000 + - JWT_SECRET_KEY=dev-pn-jwt-secret-2026-long-enough-for-hmac-signing + - WORKER_ID=dev-worker-01 + - WORKER_ENV=production + - RAILS_LOG_TO_STDOUT=1 + networks: + - backend + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 10s + max_attempts: 5 + healthcheck: + test: ["CMD", "pgrep", "-f", "sidekiq"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # React Frontend (nginx:alpine serves built SPA) + frontend: + image: git.ipnode.org/powernode/powernode-frontend:${TAG:-dev-latest} + networks: + - frontend + deploy: + replicas: 1 + restart_policy: + condition: any + delay: 5s + labels: + - traefik.enable=true + - traefik.http.routers.dev-frontend.rule=PathPrefix(`/`) + - traefik.http.routers.dev-frontend.entrypoints=web + - traefik.http.routers.dev-frontend.priority=1 + - traefik.http.services.dev-frontend.loadbalancer.server.port=80 + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80"] + interval: 30s + timeout: 5s + retries: 3 + + # Traefik v3 Reverse Proxy (HTTP-only, path-based routing) + proxy: + image: traefik:v3.0 + command: + - --api.dashboard=true + - --api.insecure=true + - --providers.swarm.endpoint=unix:///var/run/docker.sock + - --providers.swarm.exposedByDefault=false + - --providers.swarm.network=powernode-dev_frontend + - --entrypoints.web.address=:80 + ports: + - "9080:80" + - "9081:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - frontend + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + restart_policy: + condition: on-failure + +configs: + dev_cable_config: + external: true + +networks: + frontend: + driver: overlay + attachable: true + backend: + driver: overlay + internal: true + +volumes: + postgres_dev_data: + driver: local + redis_dev_data: + driver: local diff --git a/docker/swarm/monitoring.yml b/docker/swarm/monitoring.yml new file mode 100644 index 000000000..c94c41a87 --- /dev/null +++ b/docker/swarm/monitoring.yml @@ -0,0 +1,264 @@ +# Monitoring stack for Powernode Platform +# Deploy with: docker stack deploy -c monitoring.yml powernode-monitoring + +version: '3.8' + +services: + # Prometheus - Metrics collection and alerting + prometheus: + image: prom/prometheus:v2.47.0 + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + - '--web.external-url=https://prometheus.${DOMAIN:-powernode.local}' + - '--web.route-prefix=/' + - '--alertmanager.url=http://alertmanager:9093' + volumes: + - prometheus_data:/prometheus + - ../../configs/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ../../configs/monitoring/rules:/etc/prometheus/rules:ro + ports: + - "9090:9090" + networks: + - monitoring + - backend + deploy: + placement: + constraints: + - node.role == manager + resources: + limits: + memory: 1G + cpus: '0.5' + reservations: + memory: 512M + cpus: '0.2' + restart_policy: + condition: on-failure + labels: + - traefik.enable=true + - traefik.http.routers.prometheus.rule=Host(`prometheus.${DOMAIN:-powernode.local}`) + - traefik.http.routers.prometheus.entrypoints=websecure + - traefik.http.routers.prometheus.tls.certresolver=letsencrypt + - traefik.http.services.prometheus.loadbalancer.server.port=9090 + + # AlertManager - Alert routing and notifications + alertmanager: + image: prom/alertmanager:v0.26.0 + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + - '--web.external-url=https://alerts.${DOMAIN:-powernode.local}' + - '--web.route-prefix=/' + volumes: + - alertmanager_data:/alertmanager + - ../../configs/monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + ports: + - "9093:9093" + networks: + - monitoring + deploy: + placement: + constraints: + - node.role == manager + resources: + limits: + memory: 256M + cpus: '0.2' + reservations: + memory: 128M + cpus: '0.1' + restart_policy: + condition: on-failure + labels: + - traefik.enable=true + - traefik.http.routers.alertmanager.rule=Host(`alerts.${DOMAIN:-powernode.local}`) + - traefik.http.routers.alertmanager.entrypoints=websecure + - traefik.http.routers.alertmanager.tls.certresolver=letsencrypt + - traefik.http.services.alertmanager.loadbalancer.server.port=9093 + + # Grafana - Metrics visualization and dashboards + grafana: + image: grafana/grafana:10.1.0 + environment: + - GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/grafana_admin_password + - GF_USERS_ALLOW_SIGN_UP=false + - GF_INSTALL_PLUGINS=grafana-piechart-panel,grafana-worldmap-panel + - GF_SERVER_ROOT_URL=https://grafana.${DOMAIN:-powernode.local} + - GF_AUTH_ANONYMOUS_ENABLED=false + - GF_ANALYTICS_REPORTING_ENABLED=false + volumes: + - grafana_data:/var/lib/grafana + - ../../configs/monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro + - ../../configs/monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro + - ../../configs/monitoring/grafana-dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3001:3000" + networks: + - monitoring + secrets: + - grafana_admin_password + deploy: + resources: + limits: + memory: 512M + cpus: '0.3' + reservations: + memory: 256M + cpus: '0.1' + restart_policy: + condition: on-failure + labels: + - traefik.enable=true + - traefik.http.routers.grafana.rule=Host(`grafana.${DOMAIN:-powernode.local}`) + - traefik.http.routers.grafana.entrypoints=websecure + - traefik.http.routers.grafana.tls.certresolver=letsencrypt + - traefik.http.services.grafana.loadbalancer.server.port=3000 + + # Loki - Log aggregation + loki: + image: grafana/loki:2.9.0 + command: + - '-config.file=/etc/loki/local-config.yaml' + volumes: + - loki_data:/tmp/loki + - ../../configs/logging/loki-config.yml:/etc/loki/local-config.yaml:ro + ports: + - "3100:3100" + networks: + - monitoring + - backend + deploy: + resources: + limits: + memory: 512M + cpus: '0.3' + reservations: + memory: 256M + cpus: '0.1' + restart_policy: + condition: on-failure + + # Promtail - Log collection and forwarding + promtail: + image: grafana/promtail:2.9.0 + command: + - '-config.file=/etc/promtail/config.yml' + volumes: + - /var/log:/var/log:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock + - ../../configs/logging/promtail-config.yml:/etc/promtail/config.yml:ro + networks: + - monitoring + deploy: + mode: global + resources: + limits: + memory: 256M + cpus: '0.2' + reservations: + memory: 128M + cpus: '0.05' + restart_policy: + condition: on-failure + + # Node Exporter - System metrics + node-exporter: + image: prom/node-exporter:v1.6.1 + command: + - '--path.rootfs=/host' + - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' + volumes: + - /:/host:ro,rslave + ports: + - "9100:9100" + networks: + - monitoring + deploy: + mode: global + resources: + limits: + memory: 128M + cpus: '0.1' + reservations: + memory: 64M + cpus: '0.02' + restart_policy: + condition: on-failure + + # cAdvisor - Container metrics + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.47.0 + privileged: true + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + ports: + - "8080:8080" + networks: + - monitoring + deploy: + mode: global + resources: + limits: + memory: 256M + cpus: '0.2' + reservations: + memory: 128M + cpus: '0.05' + restart_policy: + condition: on-failure + + # Blackbox Exporter - Endpoint monitoring + blackbox-exporter: + image: prom/blackbox-exporter:v0.24.0 + volumes: + - ../../configs/monitoring/blackbox.yml:/etc/blackbox_exporter/config.yml:ro + ports: + - "9115:9115" + networks: + - monitoring + - frontend + deploy: + resources: + limits: + memory: 128M + cpus: '0.1' + reservations: + memory: 64M + cpus: '0.02' + restart_policy: + condition: on-failure + +networks: + monitoring: + driver: overlay + attachable: true + backend: + external: true + name: powernode_backend + frontend: + external: true + name: powernode_frontend + +volumes: + prometheus_data: + driver: local + alertmanager_data: + driver: local + grafana_data: + driver: local + loki_data: + driver: local + +secrets: + grafana_admin_password: + external: true \ No newline at end of file diff --git a/docker/swarm/production.yml b/docker/swarm/production.yml new file mode 100644 index 000000000..cc0f16187 --- /dev/null +++ b/docker/swarm/production.yml @@ -0,0 +1,260 @@ +# Docker Swarm stack configuration for Powernode Platform - Production +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_DB_FILE=/run/secrets/db_name + - POSTGRES_USER_FILE=/run/secrets/db_user + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password + volumes: + - postgres_data:/var/lib/postgresql/data + secrets: + - db_name + - db_user + - db_password + networks: + - backend + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + resources: + limits: + memory: 1G + cpus: '0.5' + reservations: + memory: 512M + cpus: '0.25' + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$(cat /run/secrets/db_user) -d $$(cat /run/secrets/db_name)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # Redis Cache + redis: + image: redis:7-alpine + command: redis-server --requirepass $$(cat /run/secrets/redis_password) + secrets: + - redis_password + volumes: + - redis_data:/data + networks: + - backend + deploy: + replicas: 1 + resources: + limits: + memory: 512M + cpus: '0.25' + reservations: + memory: 256M + cpus: '0.1' + restart_policy: + condition: on-failure + healthcheck: + test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "$$(cat /run/secrets/redis_password)", "ping"] + interval: 30s + timeout: 5s + retries: 3 + + # Rails Backend API + backend: + image: ${REGISTRY_URL}/powernode-backend:${VERSION:-latest} + environment: + - RAILS_ENV=production + - DATABASE_URL=postgresql://$$(cat /run/secrets/db_user):$$(cat /run/secrets/db_password)@postgres:5432/$$(cat /run/secrets/db_name) + - REDIS_URL=redis://:$$(cat /run/secrets/redis_password)@redis:6379/0 + - RAILS_MASTER_KEY_FILE=/run/secrets/rails_master_key + - JWT_SECRET_KEY_FILE=/run/secrets/jwt_secret + - RAILS_LOG_LEVEL=info + - WEB_CONCURRENCY=2 + - RAILS_MAX_THREADS=5 + secrets: + - db_name + - db_user + - db_password + - redis_password + - rails_master_key + - jwt_secret + networks: + - backend + - frontend + depends_on: + - postgres + - redis + deploy: + replicas: 2 + update_config: + parallelism: 1 + delay: 30s + failure_action: rollback + order: start-first + rollback_config: + parallelism: 1 + delay: 30s + resources: + limits: + memory: 1G + cpus: '0.5' + reservations: + memory: 512M + cpus: '0.25' + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # Sidekiq Worker + worker: + image: ${REGISTRY_URL}/powernode-worker:${VERSION:-latest} + environment: + - RAILS_ENV=production + - DATABASE_URL=postgresql://$$(cat /run/secrets/db_user):$$(cat /run/secrets/db_password)@postgres:5432/$$(cat /run/secrets/db_name) + - REDIS_URL=redis://:$$(cat /run/secrets/redis_password)@redis:6379/0 + - BACKEND_API_URL=http://backend:3000 + - SIDEKIQ_CONCURRENCY=10 + secrets: + - db_name + - db_user + - db_password + - redis_password + networks: + - backend + depends_on: + - postgres + - redis + - backend + deploy: + replicas: 2 + resources: + limits: + memory: 1G + cpus: '0.5' + reservations: + memory: 512M + cpus: '0.25' + restart_policy: + condition: on-failure + delay: 10s + max_attempts: 3 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4567/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # React Frontend + frontend: + image: ${REGISTRY_URL}/powernode-frontend:${VERSION:-latest} + networks: + - frontend + depends_on: + - backend + deploy: + replicas: 2 + update_config: + parallelism: 1 + delay: 10s + failure_action: rollback + resources: + limits: + memory: 256M + cpus: '0.25' + reservations: + memory: 128M + cpus: '0.1' + restart_policy: + condition: on-failure + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 5s + retries: 3 + + # Reverse Proxy / Load Balancer + proxy: + image: traefik:v3.0 + command: + - --api.dashboard=true + - --api.insecure=false + - --providers.docker.swarmMode=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=frontend + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt + networks: + - frontend + deploy: + placement: + constraints: + - node.role == manager + resources: + limits: + memory: 256M + cpus: '0.25' + restart_policy: + condition: on-failure + labels: + - traefik.enable=true + - traefik.http.routers.frontend.rule=Host(`${DOMAIN}`) + - traefik.http.routers.frontend.entrypoints=websecure + - traefik.http.routers.frontend.tls.certresolver=letsencrypt + - traefik.http.services.frontend.loadbalancer.server.port=80 + +networks: + frontend: + driver: overlay + attachable: true + backend: + driver: overlay + internal: true + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + letsencrypt: + driver: local + +secrets: + db_name: + external: true + db_user: + external: true + db_password: + external: true + redis_password: + external: true + rails_master_key: + external: true + jwt_secret: + external: true \ No newline at end of file diff --git a/docker/swarm/staging.yml b/docker/swarm/staging.yml new file mode 100644 index 000000000..184b8c698 --- /dev/null +++ b/docker/swarm/staging.yml @@ -0,0 +1,299 @@ +# Docker Swarm stack configuration for Powernode Platform - Staging +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_DB_FILE=/run/secrets/staging_db_name + - POSTGRES_USER_FILE=/run/secrets/staging_db_user + - POSTGRES_PASSWORD_FILE=/run/secrets/staging_db_password + volumes: + - postgres_staging_data:/var/lib/postgresql/data + secrets: + - staging_db_name + - staging_db_user + - staging_db_password + networks: + - backend + deploy: + replicas: 1 + placement: + constraints: + - node.labels.environment == staging + resources: + limits: + memory: 512M + cpus: '0.3' + reservations: + memory: 256M + cpus: '0.1' + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$(cat /run/secrets/staging_db_user) -d $$(cat /run/secrets/staging_db_name)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # Redis Cache + redis: + image: redis:7-alpine + command: redis-server --requirepass $$(cat /run/secrets/staging_redis_password) + secrets: + - staging_redis_password + volumes: + - redis_staging_data:/data + networks: + - backend + deploy: + replicas: 1 + resources: + limits: + memory: 256M + cpus: '0.2' + reservations: + memory: 128M + cpus: '0.05' + restart_policy: + condition: on-failure + healthcheck: + test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "$$(cat /run/secrets/staging_redis_password)", "ping"] + interval: 30s + timeout: 5s + retries: 3 + + # Rails Backend API + backend: + image: ${REGISTRY_URL}/powernode-backend:${VERSION:-latest} + environment: + - RAILS_ENV=staging + - DATABASE_URL=postgresql://$$(cat /run/secrets/staging_db_user):$$(cat /run/secrets/staging_db_password)@postgres:5432/$$(cat /run/secrets/staging_db_name) + - REDIS_URL=redis://:$$(cat /run/secrets/staging_redis_password)@redis:6379/0 + - RAILS_MASTER_KEY_FILE=/run/secrets/staging_rails_master_key + - JWT_SECRET_KEY_FILE=/run/secrets/staging_jwt_secret + - RAILS_LOG_LEVEL=debug + - WEB_CONCURRENCY=1 + - RAILS_MAX_THREADS=3 + # Staging-specific settings + - RAILS_SERVE_STATIC_FILES=true + - FORCE_SSL=false + secrets: + - staging_db_name + - staging_db_user + - staging_db_password + - staging_redis_password + - staging_rails_master_key + - staging_jwt_secret + networks: + - backend + - frontend + depends_on: + - postgres + - redis + deploy: + replicas: 1 + update_config: + parallelism: 1 + delay: 15s + failure_action: rollback + order: start-first + rollback_config: + parallelism: 1 + delay: 15s + resources: + limits: + memory: 512M + cpus: '0.3' + reservations: + memory: 256M + cpus: '0.1' + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + labels: + - traefik.enable=true + - traefik.http.routers.staging-api.rule=Host(`${STAGING_DOMAIN}`) && PathPrefix(`/api`) + - traefik.http.routers.staging-api.entrypoints=websecure + - traefik.http.routers.staging-api.tls.certresolver=letsencrypt + - traefik.http.services.staging-api.loadbalancer.server.port=3000 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # Sidekiq Worker + worker: + image: ${REGISTRY_URL}/powernode-worker:${VERSION:-latest} + environment: + - RAILS_ENV=staging + - DATABASE_URL=postgresql://$$(cat /run/secrets/staging_db_user):$$(cat /run/secrets/staging_db_password)@postgres:5432/$$(cat /run/secrets/staging_db_name) + - REDIS_URL=redis://:$$(cat /run/secrets/staging_redis_password)@redis:6379/0 + - BACKEND_API_URL=http://backend:3000 + - SIDEKIQ_CONCURRENCY=5 + secrets: + - staging_db_name + - staging_db_user + - staging_db_password + - staging_redis_password + networks: + - backend + depends_on: + - postgres + - redis + - backend + deploy: + replicas: 1 + resources: + limits: + memory: 512M + cpus: '0.3' + reservations: + memory: 256M + cpus: '0.1' + restart_policy: + condition: on-failure + delay: 10s + max_attempts: 3 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4567/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # React Frontend + frontend: + image: ${REGISTRY_URL}/powernode-frontend:${VERSION:-latest} + networks: + - frontend + depends_on: + - backend + deploy: + replicas: 1 + update_config: + parallelism: 1 + delay: 5s + failure_action: rollback + resources: + limits: + memory: 128M + cpus: '0.2' + reservations: + memory: 64M + cpus: '0.05' + restart_policy: + condition: on-failure + labels: + - traefik.enable=true + - traefik.http.routers.staging-frontend.rule=Host(`${STAGING_DOMAIN}`) + - traefik.http.routers.staging-frontend.entrypoints=websecure + - traefik.http.routers.staging-frontend.tls.certresolver=letsencrypt + - traefik.http.services.staging-frontend.loadbalancer.server.port=80 + # Add staging banner header + - traefik.http.routers.staging-frontend.middlewares=staging-headers + - traefik.http.middlewares.staging-headers.headers.customRequestHeaders.X-Environment=staging + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 5s + retries: 3 + + # Reverse Proxy / Load Balancer + proxy: + image: traefik:v3.0 + command: + - --api.dashboard=true + - --api.insecure=false + - --providers.docker.swarmMode=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=frontend + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory + ports: + - "80:80" + - "443:443" + - "8081:8080" # Different port for staging dashboard + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt_staging:/letsencrypt + networks: + - frontend + deploy: + placement: + constraints: + - node.role == manager + resources: + limits: + memory: 128M + cpus: '0.2' + restart_policy: + condition: on-failure + + # Staging-specific services + # Database seeding service (runs once) + db_seed: + image: ${REGISTRY_URL}/powernode-backend:${VERSION:-latest} + environment: + - RAILS_ENV=staging + - DATABASE_URL=postgresql://$$(cat /run/secrets/staging_db_user):$$(cat /run/secrets/staging_db_password)@postgres:5432/$$(cat /run/secrets/staging_db_name) + command: ["bundle", "exec", "rails", "db:seed"] + secrets: + - staging_db_name + - staging_db_user + - staging_db_password + - staging_rails_master_key + networks: + - backend + depends_on: + - postgres + - backend + deploy: + restart_policy: + condition: none + resources: + limits: + memory: 256M + cpus: '0.1' + +networks: + frontend: + driver: overlay + attachable: true + backend: + driver: overlay + internal: true + +volumes: + postgres_staging_data: + driver: local + redis_staging_data: + driver: local + letsencrypt_staging: + driver: local + +secrets: + staging_db_name: + external: true + staging_db_user: + external: true + staging_db_password: + external: true + staging_redis_password: + external: true + staging_rails_master_key: + external: true + staging_jwt_secret: + external: true \ No newline at end of file diff --git a/docker/swarm/vault.yml b/docker/swarm/vault.yml new file mode 100644 index 000000000..670914a0b --- /dev/null +++ b/docker/swarm/vault.yml @@ -0,0 +1,114 @@ +# HashiCorp Vault Docker Swarm Deployment +# Powernode AI Agent Community Platform +# +# Deploy with: docker stack deploy -c vault.yml powernode-vault +# +# Initial setup: +# 1. Deploy the stack +# 2. Initialize: docker exec vault operator init -key-shares=5 -key-threshold=3 +# 3. Unseal: docker exec vault operator unseal (repeat 3 times) +# 4. Configure: Run setup scripts to create policies and roles + +version: '3.8' + +services: + vault: + image: hashicorp/vault:1.18 + hostname: vault + cap_add: + - IPC_LOCK + environment: + VAULT_ADDR: "http://127.0.0.1:8200" + VAULT_API_ADDR: "http://vault:8200" + VAULT_CLUSTER_ADDR: "https://vault:8201" + VAULT_LOG_LEVEL: "info" + volumes: + - vault-data:/vault/data + - vault-logs:/vault/logs + - ../../configs/vault:/vault/config:ro + command: server + networks: + - powernode-internal + ports: + - target: 8200 + published: 8200 + protocol: tcp + mode: host + healthcheck: + test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.labels.vault == true + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + update_config: + parallelism: 1 + delay: 30s + failure_action: rollback + order: stop-first + rollback_config: + parallelism: 1 + delay: 30s + restart_policy: + condition: any + delay: 5s + max_attempts: 3 + window: 120s + labels: + - "traefik.enable=true" + - "traefik.http.routers.vault.rule=Host(`vault.powernode.internal`)" + - "traefik.http.routers.vault.tls=true" + - "traefik.http.services.vault.loadbalancer.server.port=8200" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # Vault metrics exporter for Prometheus + vault-exporter: + image: grapeshot/vault_exporter:latest + environment: + VAULT_ADDR: "http://vault:8200" + networks: + - powernode-internal + deploy: + mode: replicated + replicas: 1 + resources: + limits: + memory: 64M + cpus: '0.1' + depends_on: + - vault + +volumes: + vault-data: + driver: local + driver_opts: + type: none + o: bind + device: /var/lib/powernode/vault/data + vault-logs: + driver: local + driver_opts: + type: none + o: bind + device: /var/lib/powernode/vault/logs + +networks: + powernode-internal: + external: true + name: powernode-internal diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 000000000..86fa413d0 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,199 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **AGI Autonomy Phases 1-4**: Experience replay, goal decomposition, stigmergic coordination, pressure fields, governance monitoring, collusion detection, self-improvement, self-healing + - 10 new models, 4 MCP tool classes, 6 service namespaces, 13+ worker jobs, public + internal API controllers +- **Docker MCP Tools**: 52 tool actions across 7 classes (containers, services, stacks, clusters, hosts, images, networks/volumes) +- **Trading Extension** (`extensions/trading/`): Algorithmic trading with strategies, portfolios, risk monitoring, evolution, 9 worker jobs, 25+ migrations +- **Supply Chain Extension** (`extensions/supply-chain/`): Supply chain management module +- **Marketing Extension** (`extensions/marketing/`): Campaign management module +- **Extension Framework**: Generic extension detection via `FeatureGateService`, frontend extension navigation +- **Container Execution System**: Image builds, port allocation, sandbox management +- **DevOps Hub**: Overview dashboard, expandable network cards, stack grid layout +- **Observability Page**: Operations tab with conversation data +- Intelligence and coordination tabs on agent detail and governance pages + +### Changed +- Frontend stack layout, memory explorer, knowledge graph visualization, DevOps page architecture +- Backend network serializer enriched, context search enhanced, MCP catalog generator updated + +### Fixed +- 12+ Docker deployment fixes (JWT, Solid Cable, Doorkeeper, Alpine IPv6, CI builds) +- Mission completion state display in PhaseTimeline/PhaseCard +- Health dashboard threshold alignment, workspace conversation filtering + +### Infrastructure +- Gitea Actions CI/CD for Docker image builds +- Docker Swarm dev stack composition + +## [0.3.1] - 2026-02-28 + +### Fixed +- Untrack `.mcp.json` from version control; add `generate-mcp` command to `manage-proxy-hosts.sh` with backend probing +- Rewrite `version-bump.sh` to fix silent failures and add VERSION file support +- Replace hardcoded hostnames with `` placeholder in testing documentation + +## [0.3.0] - 2026-02-28 + +### Added +- **AI Autonomy System**: Complete agent autonomy framework with kill switch, goals, proposals, escalations, feedback, intervention policies, observations, duty cycle, and sensors +- 7 new models: `Ai::KillSwitchEvent`, `Ai::AgentGoal`, `Ai::AgentProposal`, `Ai::AgentEscalation`, `Ai::AgentFeedback`, `Ai::InterventionPolicy`, `Ai::AgentObservation` +- 8 new API controllers for autonomy management (public + internal) +- 8 sensor classes for agent behavioral observation +- 10+ new services: kill switch, escalation, feedback loop, proposal, intervention policy, observation pipeline, duty cycle, work claim, session discovery, agent outreach +- 6 new Sidekiq jobs for autonomy maintenance (observation pipeline, goal maintenance, observation cleanup, escalation timeout, proposal expiry, intervention policy tuning) +- `AiSuspensionCheckConcern` for all AI execution jobs (kill switch compliance) +- 16 new MCP tool actions: 3 kill switch (`emergency_halt`, `emergency_resume`, `kill_switch_status`) + 13 agent autonomy (`create_agent_goal`, `list_agent_goals`, `update_agent_goal`, `agent_introspect`, `propose_feature`, `send_proactive_notification`, `discover_claude_sessions`, `request_code_change`, `create_proposal`, `escalate`, `request_feedback`, `report_issue`) +- Autonomy dashboard with 6 panel components (goals, proposals, escalations, feedback, intervention policies, kill switch) +- 10 database migrations for autonomy subsystem +- Autonomy permissions seed + +## [0.2.0] - 2026-02-26 + +### Added +- **AI Orchestration System**: Complete AI workflow orchestration with database schema, models, services, API endpoints, and WebSocket channels +- **MCP Integration**: Model Context Protocol implementation with OAuth 2.1 and security hardening (2025-06-18 spec compliance) +- **Workflow Builder**: MCP nodes integration into visual workflow builder +- **GDPR Compliance**: Data privacy features including consent management and data export +- **Notification System**: Comprehensive notification infrastructure with email, in-app, and WebSocket delivery +- **Account Switcher**: UI component for managing multiple accounts +- **Privacy Features**: Enhanced user privacy controls and data management +- **Knowledge Base Enhancement**: Comprehensive knowledge base system with improved search and categorization +- **Security Scanning**: Added security scanning tools and infrastructure configurations +- **Compliance Jobs**: GDPR compliance, notification, and virus scan background jobs +- **MCP Browser**: Enhanced UI for browsing MCP servers and tools +- **Database-driven CORS**: Dynamic CORS and Vite allowed hosts management + +### Changed +- **Build System**: Migrated frontend from Create React App to Vite for faster builds +- **Reverse Proxy**: Added reverse proxy configuration with smart port detection +- **Worker Authentication**: Unified worker authentication system across services +- **Database Schema**: Comprehensive consolidation and optimization +- **Service Naming**: Renamed AI orchestration services with `ai_` prefix for clarity + +### Fixed +- Login persistence issues +- MCP streamable HTTP test assertions +- Zeitwerk autoloading conflicts for workflow services +- Non-deterministic worker test failures +- Frontend test assertions and expectations +- Hardcoded colors converted to theme classes + +### Security +- Comprehensive JWT authentication system with enhanced security +- OAuth 2.1 integration for MCP +- Security hardening across all API endpoints + +### Infrastructure +- Proxy host management scripts +- Development scripts and frontend tooling updates +- Package updates across all platform dependencies + +## [0.0.2] - 2025-08-24 + +### Added +- **Marketplace Infrastructure**: Complete app marketplace with 13 database tables +- **App Management**: Full CRUD operations for apps, plans, subscriptions, and features +- **API Endpoints**: 7 new controllers with comprehensive marketplace operations +- **Frontend Components**: 40+ new components for marketplace UI and management +- **Webhook System**: Complete webhook management with delivery tracking +- **Endpoint Management**: API endpoint configuration and analytics +- **App Analytics**: Comprehensive metrics and performance tracking +- **Permission System**: 47 new marketplace-specific permissions with audit logging +- **Database Migrations**: 4 new migrations for marketplace infrastructure +- **Documentation**: Comprehensive marketplace implementation guides and API docs + +### Changed +- **Code Quality**: Fixed 51 files with ESLint warnings (94 → 14 warnings) +- **Performance**: Added useCallback/useMemo optimizations across components +- **Navigation**: Updated structure with marketplace routes and improved UX +- **Component Architecture**: Enhanced PageContainer and TabContainer patterns +- **Database Schema**: Updated to version 2025_08_24_040830 + +### Fixed +- **TypeScript Compilation**: Resolved TS2554 and TS2304 errors in admin components +- **React Hooks**: Fixed no-use-before-define warnings by reordering function definitions +- **Template Strings**: Fixed expression warnings in fix-compilation-errors.ts +- **DateRangeFilter**: Major reorganization to resolve multiple hook dependency issues +- **AdminAPI**: Updated getUsers() method to accept optional filters parameter +- **Unused Variables**: Cleaned up unused imports and variables across codebase + +### Technical Details +- **Files Changed**: 135 files with +23,580 insertions, -297 deletions +- **Test Coverage**: All tests passing (Frontend 19/19, Backend 921/921) +- **Code Quality**: Zero TypeScript compilation errors +- **Performance**: 84% reduction in ESLint warnings +- **Architecture**: Complete marketplace infrastructure ready for production + +## [0.0.1] - 2025-08-15 + +### Added +- Initial platform foundation with Rails 8 API backend +- React TypeScript frontend with modern component architecture +- Sidekiq worker service for background job processing +- JWT authentication system with secure token handling +- Comprehensive subscription lifecycle management +- Payment gateway integrations (Stripe, PayPal) +- Money gem integration for precise financial calculations +- UUIDv7 primary keys for all database entities +- State machine implementations for subscription management +- Comprehensive audit logging system +- User management with role-based permissions +- Account delegation and impersonation capabilities +- Global notification system with theme-aware components +- Analytics dashboard with real-time metrics +- Admin panel with security settings and system management +- Email configuration and template management +- Comprehensive test suite (921+ backend, 45+ frontend tests) +- Git-Flow workflow with semantic versioning enforcement +- Development environment automation scripts +- Comprehensive documentation and setup guides + +### Changed +- Renamed services to workers for architectural clarity +- Enhanced authentication and security features +- Improved API error handling and validation +- Database schema optimizations +- Theme-aware component styling throughout platform + +### Fixed +- Analytics dashboard date range button functionality +- Component import/export consistency +- TypeScript compilation errors +- Security vulnerabilities in error handling + +### Security +- Enhanced authentication flow validation +- Improved error message sanitization +- Rate limiting implementation +- Secure JWT token handling +- PCI-compliant payment processing +- Cross-origin request protection (CORS) +- Input validation and sanitization +- Secure email delivery system + +### Infrastructure +- PostgreSQL database with optimized schema +- Redis for caching and session management +- Comprehensive development scripts +- Docker-ready configuration +- CI/CD pipeline foundation +- Automated testing and validation + +--- + +## Version History + +- `0.0.1` - Initial release with core platform features +- `0.0.2` - Marketplace infrastructure +- `0.2.0` - AI Orchestration, MCP integration, GDPR, notifications +- `0.3.0` - AI Autonomy System (kill switch, goals, proposals, escalations) +- `0.3.1` - Configuration tooling, documentation hostname cleanup, version-bump fix +- `unreleased` - AGI Phases 1-4, Docker MCP tools, Trading/Supply-Chain/Marketing extensions diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 000000000..0eb1a164d --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,369 @@ +# Development Guide + +Development reference for the Powernode platform. + +## Quick Start + +### Recommended: Systemd Services + +```bash +# First-time setup (installs units and config to /etc/powernode/) +sudo scripts/systemd/powernode-installer.sh install + +# Start all services +sudo systemctl start powernode.target + +# Check service status +sudo scripts/systemd/powernode-installer.sh status + +# Stop all services +sudo systemctl stop powernode.target + +# Restart a specific service +sudo systemctl restart powernode-backend@default +``` + +### Individual Service Control + +```bash +# Start/stop/restart individual services +sudo systemctl start powernode-backend@default +sudo systemctl start powernode-worker@default +sudo systemctl start powernode-worker-web@default +sudo systemctl start powernode-frontend@default + +# View logs for a specific service +journalctl -u powernode-backend@default -f +journalctl -u powernode-worker@default -f +journalctl -u powernode-frontend@default -f + +# View all Powernode logs +journalctl -u 'powernode-*' --since "5 min ago" +``` + +--- + +## Platform Architecture + +### At a Glance + +| Component | Technology | Location | +|-----------|------------|----------| +| Backend API | Rails 8 (API-only) | `server/` | +| Frontend | React + TypeScript + Tailwind | `frontend/` | +| Worker | Sidekiq (standalone) | `worker/` | +| Business | Git submodule | `extensions/business/` | +| Database | PostgreSQL (UUIDv7 PKs) | 396 tables | +| Cache/Queues | Redis | DB 0 (cache), DB 1 (Sidekiq) | + +### Codebase Scale + +| Layer | Count | Location | +|-------|-------|----------| +| Models | 340 | `server/app/models/` | +| Controllers | 311 | `server/app/controllers/` | +| Services | 634 | `server/app/services/` | +| Worker Jobs | 220 | `worker/app/jobs/` | +| WebSocket Channels | 17 | `server/app/channels/` | +| Database Tables | 396 | `server/db/migrate/` | +| MCP Tools | 194 | `server/app/services/ai/tools/` | +| Permissions | 543 | `server/db/seeds/` | +| Scripts | 48 | `scripts/` | + +--- + +## Model Namespaces (10) + +| Namespace | Models | Description | +|-----------|--------|-------------| +| `Account` | 3 | Multi-tenant account hierarchy, delegations | +| `Ai` | 145 | Agents, teams, workflows, memory, knowledge graph, providers, skills, tools, autonomy, observations, AGI (experience replay, goal decomposition, stigmergic coordination, pressure fields, governance, self-improvement) | +| `Chat` | 5 | Conversations, messages, attachments, sessions | +| `Database` | 2 | Database connections, query history | +| `DataManagement` | 3 | Data sanitization, retention policies | +| `Devops` | 43 | Pipelines, runners, repositories, deployments, Docker, Git providers | +| `FileManagement` | 7 | File uploads, storage backends, virus scanning | +| `KnowledgeBase` | 8 | Articles, categories, tags, comments, attachments | +| `Monitoring` | 2 | Health checks, service status | +| `Shared` | 1 | Feature gate service, shared utilities | +| Top-level | 120+ | User, Role, Permission, Plan, Subscription, Invoice, Payment, etc. | + +--- + +## Controller Namespaces (15) + +All controllers are under `Api::V1`. + +| Namespace | Controllers | Scope | +|-----------|-------------|-------| +| `admin/` | Admin panel endpoints | Account/system administration | +| `ai/` | AI feature endpoints | Agents, teams, workflows, memory, knowledge | +| `ai_workflows/` | Workflow management | Workflow CRUD and execution | +| `auth/` | Authentication | Login, register, password, 2FA, OAuth | +| `chat/` | Chat endpoints | Conversations, messages, streaming | +| `devops/` | DevOps endpoints | Pipelines, runners, deployments | +| `git/` | Git operations | Repositories, providers, webhooks | +| `integrations/` | Third-party | External service connectors | +| `internal/` | Internal APIs | Worker-to-server communication | +| `kb/` | Knowledge base | Articles, categories, tags | +| `mcp/` | MCP protocol | Tool execution, server management | +| `oauth/` | OAuth provider | Token grants, application management | +| `public/` | Public endpoints | Unauthenticated access | +| `webhooks/` | Webhook receivers | Stripe, PayPal, Git providers | +| `worker/` | Worker API | Job dispatch, status reporting | + +Plus 40 top-level controllers (accounts, users, plans, subscriptions, etc.). + +--- + +## Service Namespaces (22+) + +| Namespace | Files | Description | +|-----------|-------|-------------| +| `ai/` | 356 | Agent orchestration, providers, workflows, cost optimization, memory, knowledge, autonomy, AGI | +| `mcp/` | 101 | Node executors (50+), orchestration, conditional evaluation | +| `devops/` | 45 | CI/CD, Git operations, deployment, registry, Docker | +| `a2a/` | 17 | Agent-to-Agent protocol services | +| `chat/` | 10 | Conversation management, context building | +| `security/` | 11 | Authentication, authorization, encryption | +| `orchestration/` | 8 | Workflow orchestration coordination | +| `cost_optimization/` | 7 | Budget management, cost analysis, recommendations | +| `storage_providers/` | 7 | S3, GCS, Local, NFS, SMB storage backends | +| `concerns/` | 7 | Shared service concerns (circuit breaker, broadcasting) | +| `provider_testing/` | 6 | Connection testing, health checks, load testing | +| `shared/` | 4 | Cross-cutting utilities | +| `billing/` | 2 | Subscription lifecycle, payment processing | +| `data_management/` | 2 | Data sanitization, retention | +| `monitoring/` | 2 | Health monitoring, metrics | +| `permissions/` | 2 | Permission management | +| `rate_limiting/` | 2 | Request rate limiting | +| `audit/` | 2 | Audit log services | +| `admin/` | 2 | Admin panel services | +| `auth/` | 1 | Authentication services | +| `accounts/` | 1 | Account management | +| Others | 5 | Analytics, notifications, marketplace | + +--- + +## AI Subsystem Map + +The AI platform is the largest subsystem (356 services, 145 models). + +### Core Systems + +| System | Purpose | Key Files | +|--------|---------|-----------| +| **Agent Orchestration** | Execute AI agents with provider fallback | `ai/agent_orchestration_service.rb` | +| **Code Factory** | Automated code generation pipeline (PRD → tasks → code → review) | `ai/code_factory/` | +| **Ralph Loops** | Mission lifecycle (analyze → plan → execute → test → review → deploy → merge) | `ai_mission_*_job.rb` | +| **AGUI (Agent GUI)** | Chat-based agent interaction with streaming | `ai_conversation_channel.rb`, `chat/` | +| **Model Router** | Load balancing, circuit breaking, cost optimization across providers | `ai/provider_load_balancer_service.rb` | +| **Knowledge Graph** | Entity-relationship graph with multi-hop reasoning | `ai/knowledge_graph/` | +| **Compound Learning** | Pattern/discovery/best-practice learning with decay and reinforcement | `ai/compound_learning/` | +| **Memory Tiers** | STM → Working → LTM with consolidation and decay | `ai/memory/` | +| **MCP Protocol** | 194-tool Model Context Protocol for agent capabilities | `mcp/` | +| **A2A Protocol** | Agent-to-Agent communication and task delegation | `a2a/` | +| **Skill Registry** | Reusable agent capabilities with lifecycle management | `ai/skills/` | +| **Team Execution** | Multi-agent orchestration with role-based coordination | `ai/team_execution/` | +| **Agent Autonomy** | Kill switch, goals, proposals, escalations, observation pipeline, intervention policies | `ai/autonomy/` | +| **Experience Replay** | Execution history analysis and pattern extraction | `ai/agi/` | +| **Goal Decomposition** | Hierarchical goal breakdown and planning | `ai/agi/` | +| **Stigmergic Coordination** | Environment-mediated multi-agent coordination | `ai/agi/` | +| **Pressure Fields** | Gradient-based resource allocation and task prioritization | `ai/agi/` | +| **Governance** | Monitoring, collusion detection, behavioral analysis | `ai/agi/` | +| **Self-Improvement** | Autonomous capability enhancement and reflexion | `ai/agi/` | +| **Self-Healing** | Automated error recovery and system repair | `ai/agi/` | + +### Workflow System + +50 node executors in `mcp/node_executors/`: +- **Control flow**: start, end, condition, loop, split, merge, delay, scheduler +- **AI**: ai_agent, sub_workflow +- **Integration**: api_call, webhook, notification, email, database, file operations +- **Content**: page and KB article CRUD +- **DevOps**: CI/CD, Git operations, deployment +- **MCP**: tool, prompt, resource execution + +--- + +## Frontend Feature Modules (10) + +| Module | Path | Description | +|--------|------|-------------| +| `account` | `frontend/src/features/account/` | Account settings, profile management | +| `admin` | `frontend/src/features/admin/` | Admin panel, system management | +| `ai` | `frontend/src/features/ai/` | AI agents, workflows, chat, knowledge | +| `business` | `frontend/src/features/business/` | Billing, subscriptions, invoices | +| `content` | `frontend/src/features/content/` | CMS pages, KB articles | +| `delegations` | `frontend/src/features/delegations/` | Cross-account access delegation | +| `developer` | `frontend/src/features/developer/` | API keys, webhooks, developer tools | +| `devops` | `frontend/src/features/devops/` | Pipelines, repositories, deployments | +| `missions` | `frontend/src/features/missions/` | AI mission control (Ralph) | +| `privacy` | `frontend/src/features/privacy/` | GDPR, data export, consent | + +--- + +## Worker Job Categories (220 jobs) + +The worker is a standalone Sidekiq process that communicates with the server via HTTP API. + +| Category | Jobs | Queue | Description | +|----------|------|-------|-------------| +| AI (top-level) | 74 | `ai_agents`, `ai_workflows`, `ai_orchestration` | Agent execution, workflows, memory, knowledge, missions | +| AGI | 13 | `ai_orchestration`, `ai_agents` | Experience replay, goal decomposition, stigmergic coordination, pressure fields, governance, self-improvement, self-healing | +| AI Workflow | 2 | `ai_workflows` | Approval expiry, notifications | +| Analytics | 3 | `analytics` | Metrics aggregation, live metrics, recalculation | +| Compliance | 4 | `compliance` | GDPR data deletion, export, retention, account termination | +| DevOps | 9 | `devops_default`, `devops_high` | Pipeline steps, deployment, sync, approvals | +| Docker | 3 | `maintenance` | Health checks, host sync, event cleanup | +| File Processing | 1 | `file_processing` | Virus scanning | +| Git | 9 | `devops_default` | Repository sync, pipeline sync, webhooks, runners | +| Integrations | 3 | `integrations` | Execution, health checks, credential rotation | +| Maintenance | 5 | `maintenance` | Database backup/restore, scheduled tasks, cleanup | +| Marketing | 4 | `marketing` | Campaigns, email batches, social media | +| MCP | 10 | `mcp` | Tool execution, server health, discovery, cache | +| Notifications | 6 | `notifications`, `email` | Email, SMS, push, bulk, transactional | +| Reports | 2 | `reports` | Report generation, scheduled reports | +| Services | 5 | `services` | Health checks, service discovery, config generation | +| Swarm | 5 | `maintenance` | Docker Swarm cluster sync, stack deploy, health | +| Trading | 9 | `trading` | Strategy execution, portfolio updates, risk monitoring, evolution | +| Webhooks | 6 | `webhooks` | Stripe/PayPal processing, delivery, retry | + +33 queues configured in `worker/config/sidekiq.yml` with weighted priorities (1-3). + +--- + +## WebSocket Channels (17) + +All channels use ActionCable with JWT authentication. + +| Channel | Subscription Params | Purpose | +|---------|-------------------|---------| +| `AiAgentExecutionChannel` | `execution_id` | Agent execution monitoring | +| `AiConversationChannel` | `conversation_id` | AI chat messaging and streaming | +| `AiOrchestrationChannel` | `type`, `id` | Unified AI orchestration events | +| `AiStreamingChannel` | `execution_id` or `conversation_id` | Token-by-token AI response streaming | +| `AiWorkflowMonitoringChannel` | `workflow_id` (optional) | Workflow monitoring and analytics | +| `AiWorkflowOrchestrationChannel` | — | Account-level workflow events | +| `AnalyticsChannel` | `account_id` | Real-time analytics updates | +| `CodeFactoryChannel` | `type`, `id` | Code Factory run updates and reviews | +| `CustomerChannel` | `account_id` | Customer data updates (admin) | +| `DevopsPipelineChannel` | `account_id`, `pipeline_id` | CI/CD pipeline status | +| `GitJobLogsChannel` | `repository_id`, `pipeline_id`, `job_id` | Live pipeline job log streaming | +| `McpChannel` | — | MCP protocol WebSocket transport | +| `MissionChannel` | `type`, `id` | Mission (Ralph) progress updates | +| `NotificationChannel` | `account_id` | Real-time notifications | +| `SubscriptionChannel` | `account_id` | Subscription status changes | +| `TeamChannelChannel` | `channel_id` | Team channel messaging | +| `TeamExecutionChannel` | `team_id` | Multi-agent team execution monitoring | + +--- + +## Network Access + +### Local Development + +| Service | URL | Port | +|---------|-----|------| +| Backend API | http://localhost:3000 | 3000 | +| Frontend | http://localhost:3001 | 3001 | +| Sidekiq Web | http://localhost:4567 | 4567 | + +### Domain Access + +Add to `/etc/hosts`: +``` +127.0.0.1 powernode.dev +``` + +- **Backend API**: http://powernode.dev:3000 +- **Frontend**: http://powernode.dev:3001 + +--- + +## Configuration + +### Service Configuration + +| Service | Config File | Key Settings | +|---------|-------------|--------------| +| Global | `/etc/powernode/powernode.conf` | Base path, RVM/nvm paths, Ruby/Node versions | +| Backend | `/etc/powernode/backend-default.conf` | Port, binding, CORS | +| Worker | `/etc/powernode/worker-default.conf` | Redis URL, concurrency | +| Worker Web | `/etc/powernode/worker-web-default.conf` | Dashboard port | +| Frontend | `/etc/powernode/frontend-default.conf` | API URL, binding | + +### Multi-Instance Support + +```bash +# Add a second backend instance +sudo scripts/systemd/powernode-installer.sh add-instance backend api2 +# Edit /etc/powernode/backend-api2.conf → set PORT=3002 +sudo systemctl enable --now powernode-backend@api2 + +# Add a high-concurrency worker for AI workloads +sudo scripts/systemd/powernode-installer.sh add-instance worker ai-heavy +# Edit /etc/powernode/worker-ai-heavy.conf → set WORKER_CONCURRENCY=15 +sudo systemctl enable --now powernode-worker@ai-heavy +``` + +--- + +## Development Commands + +```bash +# Database operations +cd server && rails db:migrate db:seed + +# Backend tests +cd server && bundle exec rspec --format progress + +# Frontend tests +cd frontend && CI=true npm test + +# Type checking +cd frontend && npm run typecheck + +# Pattern validation +./scripts/quick-pattern-check.sh + +# Full validation (specs + TS + patterns) +./scripts/validate.sh +``` + +--- + +## Troubleshooting + +### Services Won't Start + +```bash +journalctl -u powernode-backend@default --since "5 min ago" --no-pager +sudo systemctl reset-failed 'powernode-*' +sudo systemctl start powernode.target +``` + +### Port Conflicts + +```bash +ss -tlnp | grep :3000 +# Change ports in /etc/powernode/backend-default.conf +sudo systemctl daemon-reload && sudo systemctl restart powernode-backend@default +``` + +### CORS Issues + +- Backend CORS is configured for localhost and powernode.dev domains +- Check browser developer tools for specific errors +- Ensure the API URL in frontend matches your setup + +--- + +## Reference + +- [CLAUDE.md](../CLAUDE.md) — Service management, testing, code quality, git workflow +- [API Response Standards](platform/API_RESPONSE_STANDARDS.md) — Unified API format +- [Permission System](platform/PERMISSION_SYSTEM_REFERENCE.md) — Access control reference +- [Theme System](platform/THEME_SYSTEM_REFERENCE.md) — Frontend styling guide +- [Backend Services](backend/BACKEND_SERVICE_ARCHITECTURE.md) — Service layer architecture +- [WebSocket Architecture](platform/WEBSOCKET_AND_REALTIME.md) — Real-time communication diff --git a/docs/GITHUB_SETUP.md b/docs/GITHUB_SETUP.md new file mode 100644 index 000000000..fff93feba --- /dev/null +++ b/docs/GITHUB_SETUP.md @@ -0,0 +1,267 @@ +# GitHub Configuration Guide for Powernode + +## 1. Create GitHub Repository + +### Option A: Using GitHub Web Interface +1. Go to https://github.com/new +2. Repository name: `powernode-platform` +3. Description: `Subscription lifecycle management platform with Rails 8 API, React TypeScript frontend, and Sidekiq worker service` +4. Choose **Private** or **Public** based on your needs +5. **DO NOT** initialize with README, .gitignore, or license (we already have these) +6. Click "Create repository" + +### Option B: Using GitHub CLI (if available) +```bash +gh repo create powernode-platform --private --description "Subscription lifecycle management platform" +``` + +## 2. Configure Git Remote + +After creating the repository, add the remote: + +```bash +# Replace YOUR_USERNAME with your GitHub username +git remote add origin https://github.com/YOUR_USERNAME/powernode-platform.git + +# Verify remote was added +git remote -v +``` + +## 3. Push Initial Code + +```bash +# Push master branch and all tags +git push -u origin master --tags + +# Push develop branch +git checkout develop +git push -u origin develop + +# Set develop as default branch for Git-Flow +git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/develop +``` + +## 4. GitHub Repository Settings + +### Branch Protection Rules + +Navigate to **Settings > Branches** and add these protection rules: + +#### For `master` branch: +- ✅ Require a pull request before merging +- ✅ Require approvals (1) +- ✅ Dismiss stale PR approvals when new commits are pushed +- ✅ Require status checks to pass before merging +- ✅ Require branches to be up to date before merging +- ✅ Require conversation resolution before merging +- ✅ Restrict pushes that create files larger than 100MB +- ✅ Do not allow bypassing the above settings + +#### For `develop` branch: +- ✅ Require a pull request before merging +- ✅ Require status checks to pass before merging +- ✅ Require branches to be up to date before merging +- ✅ Allow force pushes (for Git-Flow) + +### Repository Settings + +In **Settings > General**: + +#### Features +- ✅ Wikis (for documentation) +- ✅ Issues (for bug tracking) +- ✅ Projects (for project management) +- ✅ Discussions (for community) + +#### Pull Requests +- ✅ Allow merge commits +- ✅ Allow squash merging +- ✅ Allow rebase merging +- ✅ Always suggest updating pull request branches +- ✅ Automatically delete head branches + +## 5. GitHub Actions Configuration + +The repository already includes GitHub Actions workflows: + +### Semantic Release Workflow +- **File**: `.github/workflows/semantic-release.yml` +- **Triggers**: Push to master, pull requests +- **Features**: Testing, security audits, automated releases + +### Required Secrets + +Add these secrets in **Settings > Secrets and variables > Actions**: + +```bash +# For npm packages (if publishing) +NPM_TOKEN=your_npm_token + +# For semantic-release GitHub integration +GITHUB_TOKEN=automatically_provided + +# For deployment (optional) +DEPLOY_KEY=your_deploy_key +``` + +## 6. Issue Templates + +Create `.github/ISSUE_TEMPLATE/` directory with templates: + +### Bug Report Template +```yaml +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: ['bug', 'needs-triage'] +assignees: '' +``` + +### Feature Request Template +```yaml +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: ['enhancement', 'needs-triage'] +assignees: '' +``` + +## 7. Pull Request Template + +Create `.github/pull_request_template.md`: + +```markdown +## Description +Brief description of changes + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update + +## Testing +- [ ] Tests pass locally +- [ ] New tests added for new functionality +- [ ] Manual testing completed + +## Checklist +- [ ] Code follows project style guidelines +- [ ] Self-review completed +- [ ] Documentation updated +- [ ] No breaking changes without version bump +``` + +## 8. GitHub Pages (Optional) + +For documentation hosting: + +1. Go to **Settings > Pages** +2. Source: **Deploy from a branch** +3. Branch: **master** or **gh-pages** +4. Folder: **/ (root)** or **/docs** + +## 9. Security Configuration + +### Security Policies + +Create `.github/SECURITY.md`: + +```markdown +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | +| 0.0.x | :white_check_mark: | + +## Reporting a Vulnerability + +Please report security vulnerabilities via email to security@your-domain.com +``` + +### Dependabot Configuration + +The repository includes `.github/dependabot.yml` for automated dependency updates. + +## 10. Git-Flow GitHub Integration + +### Release Process with GitHub + +1. **Create Release Branch**: + ```bash + git flow release start 0.1.0 + ``` + +2. **Complete Release**: + ```bash + git flow release finish 0.1.0 + ``` + +3. **Push to GitHub**: + ```bash + git push origin master --tags + git push origin develop + ``` + +4. **Create GitHub Release**: + - Go to **Releases > Create a new release** + - Tag: `0.1.0` + - Title: `Release 0.1.0` + - Description: Copy from CHANGELOG.md + - Attach binaries if applicable + +## 11. Team Collaboration + +### Branch Naming Convention +- Feature branches: `feature/description-of-feature` +- Hotfix branches: `hotfix/description-of-fix` +- Release branches: `release/0.1.0` + +### Commit Message Convention +Following Conventional Commits: +``` +type(scope): description + +feat(auth): add OAuth2 integration +fix(billing): resolve subscription renewal issue +docs(api): update endpoint documentation +``` + +## Quick Setup Commands + +```bash +# 1. Add GitHub remote (replace YOUR_USERNAME) +git remote add origin https://github.com/YOUR_USERNAME/powernode-platform.git + +# 2. Push all branches and tags +git push -u origin master --tags +git checkout develop +git push -u origin develop + +# 3. Set develop as default branch +git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/develop + +# 4. Verify setup +git remote -v +git branch -a +``` + +## Troubleshooting + +### Authentication Issues +- Use personal access token instead of password +- Configure SSH keys for seamless authentication +- Enable 2FA for enhanced security + +### Large File Issues +- Use Git LFS for files > 100MB +- Add binary files to .gitignore +- Consider external storage for large assets + +### Branch Protection Bypassing +- Use organization rules for stricter enforcement +- Require admin approval for protection rule changes +- Enable audit logging for compliance \ No newline at end of file diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 000000000..d6f2de277 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,234 @@ +# Powernode Platform - Developer Quick Start Guide + +Get the Powernode platform running locally in under 10 minutes. + +## Prerequisites + +| Requirement | Version | Verify Command | +|-------------|---------|----------------| +| Node.js | 20+ | `node --version` | +| Ruby | 3.3+ | `ruby --version` | +| PostgreSQL | 16+ | `psql --version` | +| Redis | 7+ | `redis-server --version` | +| Docker | 24+ | `docker --version` | + +## Quick Setup + +### 1. Clone and Install Dependencies + +```bash +# Clone the repository +git clone https://github.com/your-org/powernode-platform.git +cd powernode-platform + +# Install dependencies +cd server && bundle install +cd ../frontend && npm install +cd ../worker && bundle install +cd .. + +# Setup database +cd server && bundle exec rails db:create db:migrate db:seed +``` + +### 2. Install and Start All Services + +```bash +# Install systemd services (one-time) +sudo scripts/systemd/powernode-installer.sh install + +# Start everything +sudo systemctl start powernode.target + +# Check status +sudo scripts/systemd/powernode-installer.sh status +``` + +### 3. Access the Application + +| Service | URL | +|---------|-----| +| Frontend | http://localhost:5173 | +| Backend API | http://localhost:3000 | +| API Health | http://localhost:3000/api/v1/health | + +### 4. Default Credentials + +Create a test user: +```bash +cd server && bundle exec rails c +User.create!(email: 'dev@example.com', password: 'DevPassword123!', name: 'Developer') +``` + +## Running Tests + +```bash +# Backend tests +cd server && pkill -f rspec 2>/dev/null; bundle exec rspec --format progress + +# Frontend tests +cd frontend && CI=true npm test + +# E2E tests (Playwright) +cd frontend && npx playwright test + +# Type checking +cd frontend && npx tsc --noEmit +``` + +## Key Files to Know + +### Backend (`/server`) + +| File | Purpose | +|------|---------| +| `config/routes.rb` | API route definitions | +| `app/controllers/api/v1/` | API controllers | +| `app/models/` | ActiveRecord models | +| `app/services/` | Business logic services | +| `spec/` | RSpec test files | + +### Frontend (`/frontend`) + +| File | Purpose | +|------|---------| +| `src/App.tsx` | Main application entry | +| `src/pages/app/` | Application pages | +| `src/features/` | Feature modules | +| `src/shared/` | Shared components and utilities | +| `tailwind.config.js` | Tailwind CSS configuration | + +### Worker (`/worker`) + +| File | Purpose | +|------|---------| +| `app/jobs/` | Background job classes | +| `config/sidekiq.yml` | Sidekiq configuration | + +## Common Development Tasks + +### Add a New API Endpoint + +1. Add route in `server/config/routes.rb` +2. Create controller in `server/app/controllers/api/v1/` +3. Use standard response helpers: + ```ruby + render_success(data: { ... }) + render_error(message: 'Error', status: :bad_request) + ``` +4. Add tests in `server/spec/requests/` + +### Add a New Frontend Page + +1. Create page in `frontend/src/pages/app/` +2. Add route in `frontend/src/App.tsx` +3. Use PageContainer for consistent layout: + ```tsx + + {/* Page content */} + + ``` + +### Add a Background Job + +1. Create job in `worker/app/jobs/` +2. Inherit from `BaseJob` and implement `execute`: + ```ruby + class MyJob < BaseJob + sidekiq_options queue: 'default' + def execute(param) + # Job logic + end + end + ``` +3. Queue from backend via API call + +## Code Quality + +Run before committing: + +```bash +# Full quality check +./scripts/pre-commit-quality-check.sh + +# Individual checks +./scripts/quick-pattern-check.sh # Fast pattern validation +./scripts/fix-hardcoded-colors.sh # Fix theme violations +./scripts/cleanup-all-console-logs.sh # Remove console.log +``` + +## Architecture Overview + +``` +powernode-platform/ +├── server/ # Rails 8 API backend +│ ├── app/ +│ │ ├── controllers/api/v1/ # API endpoints +│ │ ├── models/ # ActiveRecord models +│ │ └── services/ # Business logic +│ └── spec/ # RSpec tests +│ +├── frontend/ # React TypeScript frontend +│ ├── src/ +│ │ ├── features/ # Feature modules +│ │ ├── pages/ # Page components +│ │ └── shared/ # Shared utilities +│ └── e2e/ # E2E tests (Playwright) +│ +├── worker/ # Sidekiq background jobs +│ └── app/jobs/ # Job classes +│ +├── docs/ # Documentation +├── scripts/ # Development scripts +└── docker/ # Docker configurations +``` + +## Troubleshooting + +### Services Won't Start + +```bash +# Check logs for errors +journalctl -u powernode-backend@default --since "5 min ago" --no-pager + +# Reset failed state and restart +sudo systemctl reset-failed 'powernode-*' +sudo systemctl start powernode.target +``` + +### Database Issues + +```bash +cd server +bundle exec rails db:reset # WARNING: Drops and recreates DB +bundle exec rails db:migrate +``` + +### Frontend Build Errors + +```bash +cd frontend +rm -rf node_modules +npm install +npm run typecheck +``` + +### Redis Connection Issues + +```bash +# Check Redis is running +redis-cli ping +# Should return: PONG +``` + +## Getting Help + +- **Internal docs**: See `/docs/` directory +- **Specialist guides**: See `/docs/backend/`, `/docs/frontend/` +- **Architecture decisions**: See `/docs/platform/` + +## Next Steps + +- Review [DEVELOPMENT.md](DEVELOPMENT.md) for detailed development guidelines +- Check [PERMISSION_SYSTEM_REFERENCE.md](platform/PERMISSION_SYSTEM_REFERENCE.md) for access control +- Read [THEME_SYSTEM_REFERENCE.md](platform/THEME_SYSTEM_REFERENCE.md) for styling guidelines diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..b4c42bab3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,103 @@ +# Powernode Platform Documentation + +This directory contains comprehensive documentation for the Powernode subscription platform. + +## Quick Access +- **[TODO.md](TODO.md)** - Project tracking and development status (auto-generated from MCP shared knowledge) +- **[CHANGELOG.md](CHANGELOG.md)** - Version history and release notes +- **[DEVELOPMENT.md](DEVELOPMENT.md)** - Development setup and workflow +- **[Backend Test Engineer](testing/BACKEND_TEST_ENGINEER_SPECIALIST.md)** - Rails testing specialist +- **[Frontend Test Engineer](testing/FRONTEND_TEST_ENGINEER_SPECIALIST.md)** - React testing specialist + +## Directory Structure + +### Platform Documentation (`platform/`) +System-wide architectural and integration documentation: +- **Permission System**: Complete permission-based access control implementation +- **Pattern Analysis**: Platform standardization and compliance documentation (95%+ consistency) +- **MCP Configuration**: Model Context Protocol setup and automated delegation +- **UUID System**: UUIDv7 implementation across all 340+ platform models +- **Accessibility Standards**: Platform accessibility compliance and guidelines +- **Platform Audit Strategy**: Comprehensive platform analysis and monitoring + +### Backend Documentation (`backend/`) +Rails API specialist documentation: +- **[Rails Architect](backend/RAILS_ARCHITECT_SPECIALIST.md)** - Rails 8 API architecture and patterns +- **[Data Modeler](backend/DATA_MODELER_SPECIALIST.md)** - Database schema and ActiveRecord patterns +- **[Payment Integration](backend/PAYMENT_INTEGRATION_SPECIALIST.md)** - Stripe/PayPal integration +- **[API Developer](backend/API_DEVELOPER_SPECIALIST.md)** - RESTful API design patterns +- **[Billing Engine](backend/BILLING_ENGINE_DEVELOPER_SPECIALIST.md)** - Subscription lifecycle management +- **[Background Jobs](backend/BACKGROUND_JOB_ENGINEER_SPECIALIST.md)** - Sidekiq worker patterns + +### Frontend Documentation (`frontend/`) +React TypeScript specialist documentation: +- **[React Architect](frontend/REACT_ARCHITECT_SPECIALIST.md)** - TypeScript architecture and state management +- **[UI Components](frontend/UI_COMPONENT_DEVELOPER_SPECIALIST.md)** - Design system and reusable components +- **[Dashboard Specialist](frontend/DASHBOARD_SPECIALIST.md)** - Interactive charts and analytics +- **[Admin Panel](frontend/ADMIN_PANEL_DEVELOPER_SPECIALIST.md)** - Administrative interface development + +### Testing Documentation (`testing/`) +Comprehensive testing framework and methodologies: +- **[Backend Test Engineer](testing/BACKEND_TEST_ENGINEER_SPECIALIST.md)** - Rails testing specialist guide (RSpec patterns) +- **[Frontend Test Engineer](testing/FRONTEND_TEST_ENGINEER_SPECIALIST.md)** - React testing specialist guide (Jest/Cypress) + +### Infrastructure Documentation (`infrastructure/`) +DevOps and system administration: +- **[DevOps Engineer](infrastructure/DEVOPS_ENGINEER_SPECIALIST.md)** - CI/CD, deployment, monitoring +- **[Security Specialist](infrastructure/SECURITY_SPECIALIST.md)** - Application security and compliance +- **[Performance Optimizer](infrastructure/PERFORMANCE_OPTIMIZER.md)** - Performance tuning and optimization + +### Service Documentation (`services/`) +Specialized service implementations: +- **[Analytics Engineer](services/ANALYTICS_ENGINEER.md)** - Business intelligence and KPIs +- **[Documentation Specialist](services/DOCUMENTATION_SPECIALIST.md)** - API documentation and knowledge base +- **[Notification Engineer](services/NOTIFICATION_ENGINEER.md)** - Email, SMS, and real-time notifications + +### Worker Documentation (`worker/`) +Background processing documentation: +- See **[Background Jobs Specialist](backend/BACKGROUND_JOB_ENGINEER_SPECIALIST.md)** for worker patterns + +## Additional Documentation Files + +### Quick Reference Guides +- **[QUICKSTART](QUICKSTART.md)** - Getting started guide +- **[DEVELOPMENT](DEVELOPMENT.md)** - Development setup and workflow + +## Additional Documentation Locations + +For service-specific implementation documentation: +- **Backend** (`../server/docs/`): Rails API, models, services, and backend architecture +- **Frontend** (`../frontend/docs/`): React components, styling, and UI patterns +- **Worker** (`../worker/docs/`): Sidekiq jobs, background processing, and queue management + +## Platform Status + +The platform has achieved: +- ✅ **Comprehensive Test Coverage** - Tests passing across frontend and backend +- ✅ **Complete Documentation** - Comprehensive guides with 18+ specialist documentation files +- ✅ **Standardized Patterns** - 95%+ pattern consistency across all services +- ✅ **Production Ready** - Full-stack subscription platform with payment integration +- ✅ **Testing Excellence** - Comprehensive test suite with 25+ new test files +- ✅ **Documentation Hygiene** - Organized documentation structure with proper file organization + +## Key Platform Documentation Files + +### Platform Architecture & Standards +- **[Permission System Reference](platform/PERMISSION_SYSTEM_REFERENCE.md)** - Complete permission system reference +- **[UUID System Implementation](platform/UUID_SYSTEM_IMPLEMENTATION.md)** - UUIDv7 system documentation +- **[MCP Configuration](platform/MCP_CONFIGURATION.md)** - Model Context Protocol setup and tools +- **[AI Orchestration Guide](platform/AI_ORCHESTRATION_GUIDE.md)** - AI agent and workflow architecture +- **[Theme System Reference](platform/THEME_SYSTEM_REFERENCE.md)** - Theme system documentation +- **[API Response Standards](platform/API_RESPONSE_STANDARDS.md)** - API response format standards + +### Compliance & Analysis +- **[Accessibility Compliance Standards](platform/ACCESSIBILITY_COMPLIANCE_STANDARDS.md)** - Platform accessibility standards + +## Documentation Standards + +All documentation follows the standardized organization: +- **Platform-Level**: Cross-component system documentation +- **Component-Level**: Service-specific implementation details +- **Feature-Level**: Individual feature documentation and guides + +For development guidance, see the main [CLAUDE.md](../CLAUDE.md) configuration file. \ No newline at end of file diff --git a/docs/SAMPLE_MARKETPLACE_APP_DOCUMENTATION.md b/docs/SAMPLE_MARKETPLACE_APP_DOCUMENTATION.md new file mode 100644 index 000000000..2186a1dcc --- /dev/null +++ b/docs/SAMPLE_MARKETPLACE_APP_DOCUMENTATION.md @@ -0,0 +1,199 @@ +# Sample Marketplace App Documentation + +## Overview + +This documentation covers the **Simple Weather API** sample marketplace app that demonstrates the complete marketplace functionality in the Powernode platform. + +## App Details + +- **Name**: Simple Weather API +- **Slug**: `simple-weather-api` +- **ID**: `efb0390f-e9e7-4e54-b777-d5060520836e` +- **Category**: Weather +- **Status**: Published +- **Created**: Sample seed data + +## Marketplace Listing + +### Basic Information +- **Title**: "Simple Weather API - Get Weather Data Instantly" +- **Short Description**: "Easy-to-use weather API with current conditions and forecasts for any location worldwide." +- **Category**: `weather` +- **Tags**: `["weather", "forecast", "api", "simple"]` +- **Review Status**: Approved +- **Featured**: No +- **Published**: Yes + +### URLs +- **Documentation**: https://docs.example.com +- **Support**: https://support.example.com +- **Homepage**: https://weather-api.example.com + +### Screenshots +1. `https://cdn.weathertech.example.com/screenshots/dashboard.png` +2. `https://cdn.weathertech.example.com/screenshots/api-response.png` +3. `https://cdn.weathertech.example.com/screenshots/analytics.png` + +## App Plans + +The sample app includes two pricing tiers for subscription testing: + +### 1. Free Tier +- **Price**: $0.00 (Free) +- **Billing**: Monthly +- **Description**: "Basic weather data access with limited requests" +- **Features**: `["basic_weather"]` +- **Permissions**: `["weather.read"]` +- **Limits**: + - `requests_per_day`: 100 + - `locations`: 5 +- **Popular**: No + +### 2. Standard Plan +- **Price**: $29.00/month +- **Billing**: Monthly +- **Description**: "Enhanced weather data with more requests and features" +- **Features**: `["basic_weather"]` +- **Permissions**: `["weather.read"]` +- **Limits**: + - `requests_per_day`: 5,000 + - `locations`: 50 +- **Popular**: Yes (recommended) + +## API Endpoints + +### Public Marketplace Access +```bash +# Get all marketplace listings (includes sample app) +GET http://localhost:3000/api/v1/marketplace_listings + +# Response includes app plans: +{ + "success": true, + "data": [{ + "title": "Simple Weather API - Get Weather Data Instantly", + "app": { + "slug": "simple-weather-api", + "app_plans": [ + { + "name": "Free Tier", + "formatted_price": "Free", + "billing_interval": "monthly" + }, + { + "name": "Standard Plan", + "formatted_price": "$29.0/month", + "billing_interval": "monthly" + } + ] + } + }] +} +``` + +### Authentication Required Endpoints +```bash +# Get app details (requires authentication) +GET http://localhost:3000/api/v1/apps/{app_id} + +# Create app subscription (requires authentication) +POST http://localhost:3000/api/v1/app_subscriptions +{ + "app_id": "efb0390f-e9e7-4e54-b777-d5060520836e", + "app_plan_id": "{plan_id}" +} +``` + +## Testing Workflow + +### 1. Frontend Marketplace Display +1. Navigate to `http://localhost:3001/app/marketplace` +2. Verify "Simple Weather API" appears in listings +3. Check app shows pricing plans +4. Verify screenshots and app details display correctly + +### 2. App Installation/Subscription Flow +1. Click on the sample app in marketplace +2. View app details page with pricing plans +3. Select a pricing plan (Free Tier or Standard) +4. Complete subscription process +5. Verify subscription appears in user's installed apps + +### 3. API Verification +```bash +# Test marketplace API +curl -s "http://localhost:3000/api/v1/marketplace_listings" | jq '.data[] | select(.app.slug == "simple-weather-api")' + +# Test app plans included +curl -s "http://localhost:3000/api/v1/marketplace_listings" | jq '.data[] | select(.app.slug == "simple-weather-api") | .app.app_plans' +``` + +## Database Verification + +```ruby +# Rails console verification +app = App.find_by(slug: 'simple-weather-api') +puts "App: #{app.name}" +puts "Status: #{app.status}" +puts "Plans: #{app.app_plans.count}" +puts "Listing: #{app.marketplace_listing&.title}" +puts "Published: #{app.marketplace_listing&.published_at ? 'Yes' : 'No'}" +``` + +## Seed Scripts Used + +1. **Main App Creation**: `db/seeds/simple_marketplace_app.rb` + - Creates basic app with marketplace listing + - Sets up screenshots and app metadata + +2. **App Plans Creation**: `db/seeds/simple_app_plans.rb` + - Adds Free Tier and Standard Plan + - Configures pricing, features, and limits + +## Frontend Integration + +The sample app demonstrates: +- **Marketplace browsing**: App appears in public marketplace listings +- **App details view**: Individual app page with plans and information +- **Subscription flow**: Users can subscribe to different pricing tiers +- **Plan comparison**: Side-by-side pricing comparison +- **Installation tracking**: Subscription management interface + +## Key Features Demonstrated + +### Backend +- ✅ App model with published status +- ✅ MarketplaceListing with approved status +- ✅ AppPlan with multiple pricing tiers +- ✅ Public API endpoints for marketplace browsing +- ✅ Authentication-protected subscription endpoints +- ✅ Screenshot URL handling fix +- ✅ Tab scrollbar hiding improvements + +### Frontend +- ✅ Marketplace page displays sample apps +- ✅ App detail pages with pricing information +- ✅ Plan selection and subscription workflow +- ✅ Responsive design with theme support +- ✅ Hidden scrollbars on tab navigation + +## Usage for Development + +This sample app serves as: +1. **Testing data** for marketplace functionality +2. **Reference implementation** for app structure +3. **Demo content** for showcasing platform capabilities +4. **Integration testing** baseline for subscription workflows + +## Maintenance Notes + +- Sample data is preserved across database resets +- Seed scripts are idempotent (can run multiple times safely) +- App IDs are generated, so reference by slug for consistency +- Plans can be modified for testing different pricing scenarios + +--- + +**Created**: Sample marketplace app implementation +**Last Updated**: Tab scrollbar improvements and screenshot URL fixes +**Status**: Complete and ready for demonstration \ No newline at end of file diff --git a/docs/SECURITY_QUICK_START.md b/docs/SECURITY_QUICK_START.md new file mode 100644 index 000000000..4b3690624 --- /dev/null +++ b/docs/SECURITY_QUICK_START.md @@ -0,0 +1,145 @@ +# Security Quick Start Guide + +## 🚨 IMMEDIATE ACTION REQUIRED + +### Current Security Risk +- **CRITICAL**: Sensitive files (.env, keys, secrets) are tracked in git history +- **EXPOSURE**: JWT secrets, API keys, database passwords visible in all commits +- **IMPACT**: Development credentials compromised, production at risk + +### Quick Execution Steps + +#### 1. Run Security Cleanup Script (5 minutes) +```bash +# Execute the automated cleanup +./scripts/security-cleanup.sh + +# This will: +# - Remove sensitive files from git index +# - Update .gitignore with comprehensive patterns +# - Create proper .env.example files +# - Commit the security improvements +``` + +#### 2. Rewrite Git History (10-30 minutes) +Choose one method: + +**Method A: git-filter-repo (Recommended)** +```bash +# Install if needed +pip3 install git-filter-repo + +# Remove sensitive files from entire history +git filter-repo --path server/.env --invert-paths +git filter-repo --path worker/.env --invert-paths +git filter-repo --path worker/.session.key --invert-paths +git filter-repo --path server/config/master.key --invert-paths +``` + +**Method B: BFG Repo-Cleaner (Alternative)** +```bash +# Download BFG +wget https://repo1.maven.org/maven2/com/madgag/bfg/1.14.0/bfg-1.14.0.jar + +# Clean history +java -jar bfg-1.14.0.jar --delete-files ".env" . +java -jar bfg-1.14.0.jar --delete-files "*.key" . +java -jar bfg-1.14.0.jar --delete-files "*secret*" . + +# Cleanup +git reflog expire --expire=now --all +git gc --prune=now --aggressive +``` + +#### 3. Force Push Changes (2 minutes) +```bash +# Push cleaned history (DESTRUCTIVE - coordinates with team first) +git push --force-with-lease origin main +git push --force-with-lease origin develop +``` + +#### 4. Regenerate All Secrets (10-15 minutes) +```bash +# Copy example files +cp server/.env.example server/.env +cp worker/.env.example worker/.env +cp frontend/.env.example frontend/.env + +# Edit with new values - NEVER reuse old secrets +``` + +**Generate Secure Secrets:** +```bash +# JWT Secret (256-bit) +openssl rand -hex 32 + +# Rails Master Key +rails secret + +# Database Password +openssl rand -base64 32 | tr -d "=+/" | cut -c1-25 + +# Session Key +openssl rand -hex 32 +``` + +### Critical Security Actions + +#### Must Regenerate (NEVER reuse exposed values): +- [ ] JWT_SECRET_KEY +- [ ] Rails config/master.key +- [ ] Database passwords +- [ ] Stripe API keys (test and live) +- [ ] PayPal credentials +- [ ] Worker authentication tokens +- [ ] Session encryption keys + +#### Team Coordination: +1. **Notify team BEFORE history rewrite** +2. **All team members must re-clone after force push** +3. **Update CI/CD systems with new secrets** +4. **Verify production systems use different secrets** + +### Verification Steps + +```bash +# Verify sensitive files are ignored +echo "TEST_SECRET=123" > test.env +git add test.env # Should be ignored +git status # Should not show test.env +rm test.env + +# Check history is clean +git log --all --grep="secret\|password\|key" --oneline +git log --all -S "JWT_SECRET_KEY" --oneline # Should be empty + +# Test application +sudo systemctl start powernode.target # Should start normally +``` + +### Post-Cleanup Checklist + +- [ ] Git history cleaned (no sensitive files in any commit) +- [ ] All secrets regenerated (never reuse exposed values) +- [ ] .env files created from .example templates +- [ ] Application starts and functions normally +- [ ] Team notified and repositories re-cloned +- [ ] CI/CD updated with new secrets +- [ ] Production deployment verified secure + +## Documentation References + +- **Complete Plan**: `docs/platform/SECURITY_CLEANUP_PLAN.md` +- **Automated Script**: `scripts/security-cleanup.sh` +- **Environment Setup**: Individual `.env.example` files + +## Support + +If you encounter issues: +1. Check backup created in `../powernode-platform-backup-*` +2. Review detailed plan in `SECURITY_CLEANUP_PLAN.md` +3. Verify all team coordination before force push + +**Time Estimate**: 30-60 minutes total (depending on method chosen) +**Team Impact**: All members must re-clone after completion +**Risk Level After**: LOW (with proper secret regeneration) \ No newline at end of file diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 000000000..08c0a36c1 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,82 @@ +# Powernode Platform — TODO + +> Auto-generated by `rails mcp:sync_docs` on 2026-03-15 05:30 UTC +> **Do not edit manually** — changes will be overwritten on next sync. +> Source: `ai_shared_knowledges` | Filter: tagged "todo" + +--- + +**16 items** exported (max 100) + +## Phase 6: DevOps & Production (8) + +- [ ] Configure PostgreSQL production database + Set up production PostgreSQL with pgvector extension, connection pooling, and backup strategy. + *Priority: high* + +- [ ] Set up production hosting environment + Configure production hosting environment for Rails API, React frontend, and Sidekiq worker. + *Priority: high* + +- [ ] PCI DSS compliance certification + Complete PCI DSS compliance certification for payment processing (enterprise). + *Priority: high* + +- [ ] Final security audit and penetration testing + Conduct final security audit and penetration testing before production launch. + *Priority: high* + +- [ ] Set up security monitoring and incident response + Implement security monitoring, alerting, and incident response procedures. + *Priority: high* + +- [ ] Configure CDN for static assets + Set up CDN for frontend static assets to improve load times and reduce server load. + *Priority: low* + +- [ ] Create compliance documentation + Create comprehensive compliance documentation for security, privacy, and regulatory requirements. + *Priority: medium* + +- [ ] Configure log aggregation and analysis + Set up centralized log aggregation and analysis for all platform services. + *Priority: medium* + +## Phase 5: Quality Assurance (3) + +- [ ] Test optimization session 4 targeting 97-98% pass rate + Session 4 optimization phase targeting 97-98% frontend test pass rate. Session 3 achieved 93.6-97.2%. + *Priority: low* + +- [ ] Cross-browser compatibility testing + Set up and run cross-browser compatibility testing across Chrome, Firefox, Safari, and Edge. + *Priority: low* + +- [ ] Accessibility testing and compliance + Implement accessibility testing and ensure WCAG compliance across all frontend components. + *Priority: medium* + +## Phase 3: Analytics & Reporting (1) + +- [ ] Build PDF report generation + Implement PDF report generation for revenue, subscription, and analytics reports. CSV export is already complete. + *Priority: low* + +## Phase 1: Backend Foundation (4) + +- [ ] Add integration tests for critical user flows + Add integration tests for critical user flows including signup, subscription lifecycle, and payment processing. + *Priority: medium* + +- [ ] Write comprehensive model tests + Write comprehensive model tests covering validations, associations, and business logic for all core models. + *Priority: medium* + +- [ ] Create model factories for all core models + Create comprehensive FactoryBot factories for all core models. Some factories exist but need full coverage. + *Priority: medium* + +- [ ] Implement controller tests for authentication + Add controller-level tests for authentication endpoints (login, logout, token refresh, password reset). + *Priority: medium* + diff --git a/docs/backend/API_DEVELOPER_SPECIALIST.md b/docs/backend/API_DEVELOPER_SPECIALIST.md new file mode 100644 index 000000000..e8f70a580 --- /dev/null +++ b/docs/backend/API_DEVELOPER_SPECIALIST.md @@ -0,0 +1,1326 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# API Developer Specialist Guide + +## Related References + +For common patterns used across multiple specialists, see these consolidated references: +- **[API Response Standards](../platform/API_RESPONSE_STANDARDS.md)** - Unified response format documentation +- **[Permission System Reference](../platform/PERMISSION_SYSTEM_REFERENCE.md)** - Backend/frontend permission patterns + +## Role & Responsibilities + +The API Developer specializes in creating RESTful API endpoints with proper serialization, error handling, and documentation for Powernode's subscription platform. + +### Core Responsibilities +- Implementing CRUD API endpoints +- Handling API versioning and serialization +- Implementing proper error handling +- Adding API documentation +- Optimizing API performance + +### Key Focus Areas +- RESTful design principles and conventions +- JSON API serialization patterns +- Comprehensive error handling and validation +- API performance optimization +- Security best practices for API endpoints + +## API Development Standards + +### 1. Standard API Response Format (CRITICAL) + +#### Mandatory Response Structure +All API endpoints MUST use the standardized `ApiResponse` concern for consistent response formatting: + +```ruby +# Success Response Format +{ + success: true, + data: object_or_array, # Required: actual response data + meta?: { pagination: {...} } # Optional: metadata (pagination, etc.) +} + +# Error Response Format +{ + success: false, + error: "Primary error message", # Required: user-friendly error + code?: "ERROR_CODE", # Optional: machine-readable code + details?: { errors: [...] } # Optional: detailed error info +} +``` + +#### Using ApiResponse Concern (MANDATORY) +All controllers inherit from `ApplicationController` which includes `ApiResponse` concern: + +```ruby +class Api::V1::UsersController < ApplicationController + # ApiResponse concern is automatically included + + def index + users = current_account.users.page(pagination_params[:page]) + .per(pagination_params[:per_page]) + + # Use standardized response methods + render_paginated(users, serializer: UserSerializer) + end + + def show + user = current_account.users.find(params[:id]) + render_success(UserSerializer.new(user).as_json) + rescue ActiveRecord::RecordNotFound + render_not_found("User") + end + + def create + user = current_account.users.build(user_params) + + if user.save + render_created(UserSerializer.new(user).as_json) + else + render_validation_error(user.errors) + end + end + + def update + user = current_account.users.find(params[:id]) + + if user.update(user_params) + render_success(UserSerializer.new(user).as_json) + else + render_validation_error(user.errors) + end + end + + def destroy + user = current_account.users.find(params[:id]) + user.destroy! + render_no_content + end + + private + + def user_params + params.require(:user).permit(:email, :first_name, :last_name) + end +end +``` + +#### ApiResponse Methods Reference +```ruby +# Success responses +render_success(data = nil, status: :ok, meta: nil) +render_created(data = nil, location: nil) +render_no_content + +# Error responses +render_error(message, status: :bad_request, code: nil, details: nil) +render_validation_error(errors) +render_not_found(resource = "Resource") +render_unauthorized(message = "Authentication required") +render_forbidden(message = "Access denied") +render_internal_error(message = "Internal server error", exception: nil) + +# Specialized responses +render_paginated(collection, serializer: nil) +render_bulk_response(successful = [], failed = []) +``` + +**CRITICAL**: Always use `ApiResponse` concern methods. Never manually create `render json:` responses. Frontend code depends on consistent `success` boolean and `data` structure. + +### 2. Controller Architecture (MANDATORY) + +#### Base API Controller Pattern +```ruby +# app/controllers/api/v1/base_controller.rb +class Api::V1::BaseController < ApplicationController + include Authentication + include ErrorHandling + include RateLimiting + include Pagination + + before_action :set_api_version + before_action :authenticate_request + around_action :log_api_request + + protected + + def set_api_version + response.headers['API-Version'] = 'v1' + response.headers['Content-Type'] = 'application/json' + end + + def success_response(data, message = nil, status = :ok) + render json: { + success: true, + data: data, + message: message, + meta: response_meta + }.compact, status: status + end + + def error_response(error, details = {}, status = :bad_request) + render json: { + success: false, + error: error, + details: details, + meta: response_meta + }, status: status + end + + def response_meta + { + timestamp: Time.current.iso8601, + api_version: 'v1', + request_id: request.request_id + } + end + + def paginate_collection(collection, per_page: 20) + page = params[:page]&.to_i || 1 + per_page = [params[:per_page]&.to_i || per_page, 100].min + + collection.page(page).per(per_page) + end + + def pagination_meta(collection) + { + current_page: collection.current_page, + total_pages: collection.total_pages, + total_count: collection.total_count, + per_page: collection.limit_value, + has_next: collection.next_page.present?, + has_prev: collection.prev_page.present? + } + end +end +``` + +#### Standard CRUD Controller Implementation +```ruby +# app/controllers/api/v1/subscriptions_controller.rb +class Api::V1::SubscriptionsController < Api::V1::BaseController + before_action :set_subscription, only: [:show, :update, :destroy] + before_action :validate_subscription_params, only: [:create, :update] + + # GET /api/v1/subscriptions + def index + subscriptions = current_user.account + .subscriptions + .includes(:plan, :payments, :invoices) + .order(created_at: :desc) + + paginated = paginate_collection(subscriptions) + + success_response( + paginated.map { |sub| subscription_data(sub) }, + "Retrieved #{paginated.count} subscriptions", + :ok + ).tap do |response| + response[:meta][:pagination] = pagination_meta(paginated) + end + end + + # GET /api/v1/subscriptions/:id + def show + success_response( + subscription_data(@subscription, include_details: true), + "Subscription retrieved successfully" + ) + end + + # POST /api/v1/subscriptions + def create + service_result = SubscriptionCreationService.call( + account: current_user.account, + plan: Plan.find(subscription_params[:plan_id]), + payment_method_id: subscription_params[:payment_method_id] + ) + + if service_result.success? + success_response( + service_result.data[:subscription], + "Subscription created successfully", + :created + ) + else + error_response( + service_result.error, + service_result.details, + :unprocessable_entity + ) + end + end + + # PATCH/PUT /api/v1/subscriptions/:id + def update + if @subscription.update(subscription_update_params) + # Delegate complex updates to service layer + if subscription_params[:plan_id] && subscription_params[:plan_id] != @subscription.plan_id + service_result = SubscriptionUpdateService.call( + subscription: @subscription, + new_plan: Plan.find(subscription_params[:plan_id]) + ) + + unless service_result.success? + return error_response(service_result.error, service_result.details, :unprocessable_entity) + end + end + + success_response( + subscription_data(@subscription.reload), + "Subscription updated successfully" + ) + else + error_response( + "Update failed", + @subscription.errors.full_messages, + :unprocessable_entity + ) + end + end + + # DELETE /api/v1/subscriptions/:id + def destroy + service_result = SubscriptionCancellationService.call(subscription: @subscription) + + if service_result.success? + success_response( + { cancelled_at: Time.current.iso8601 }, + "Subscription cancelled successfully" + ) + else + error_response(service_result.error, service_result.details, :unprocessable_entity) + end + end + + # GET /api/v1/subscriptions/:id/payments + def payments + payments = @subscription.payments + .includes(:payment_method) + .order(created_at: :desc) + + paginated = paginate_collection(payments) + + success_response( + paginated.map { |payment| payment_data(payment) } + ).tap do |response| + response[:meta][:pagination] = pagination_meta(paginated) + end + end + + private + + def set_subscription + @subscription = current_user.account.subscriptions.find(params[:id]) + rescue ActiveRecord::RecordNotFound + error_response("Subscription not found", {}, :not_found) + end + + def subscription_params + params.require(:subscription).permit(:plan_id, :payment_method_id, :status) + end + + def subscription_update_params + params.require(:subscription).permit(:plan_id) + end + + def validate_subscription_params + return unless params[:subscription] + + errors = [] + + if action_name == 'create' + errors << "Plan ID is required" unless params[:subscription][:plan_id].present? + errors << "Payment method ID is required" unless params[:subscription][:payment_method_id].present? + end + + if params[:subscription][:plan_id].present? + plan = Plan.find_by(id: params[:subscription][:plan_id]) + errors << "Invalid plan ID" unless plan + end + + if errors.any? + error_response("Validation failed", errors, :bad_request) + end + end + + def subscription_data(subscription, include_details: false) + base_data = { + id: subscription.id, + status: subscription.status, + plan: { + id: subscription.plan.id, + name: subscription.plan.name, + price: subscription.plan.price.format, + billing_interval: subscription.plan.billing_interval + }, + current_period: { + start: subscription.current_period_start&.iso8601, + end: subscription.current_period_end&.iso8601 + }, + created_at: subscription.created_at.iso8601, + updated_at: subscription.updated_at.iso8601 + } + + if include_details + base_data.merge!( + payment_methods: subscription.account.payment_methods.active.map { |pm| payment_method_data(pm) }, + recent_payments: subscription.payments.recent.limit(5).map { |p| payment_data(p) }, + next_billing_date: subscription.next_billing_date&.iso8601, + cancellation: subscription.cancelled? ? { + cancelled_at: subscription.cancelled_at&.iso8601, + cancellation_reason: subscription.cancellation_reason + } : nil + ) + end + + base_data.compact + end + + def payment_data(payment) + { + id: payment.id, + amount: payment.amount.format, + currency: payment.currency, + status: payment.status, + payment_method: payment.payment_method ? payment_method_data(payment.payment_method) : nil, + processed_at: payment.processed_at&.iso8601, + created_at: payment.created_at.iso8601 + }.compact + end + + def payment_method_data(payment_method) + { + id: payment_method.id, + type: payment_method.method_type, + display_name: payment_method.display_name, + is_default: payment_method.account.default_payment_method_id == payment_method.id + } + end +end +``` + +### 3. Serialization Standards (CRITICAL) + +#### Consistent Data Serialization Pattern +**MANDATORY**: All model data must be serialized through standardized methods, never expose raw ActiveRecord objects. + +```ruby +# app/controllers/concerns/user_serialization.rb +module UserSerialization + def user_data(user, include_roles: false) + { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + full_name: "#{user.first_name} #{user.last_name}".strip, + status: user.status, + permissions: user.all_permissions, # Always include permissions + roles: include_roles ? user.roles.map(&:name) : nil, + created_at: user.created_at.iso8601, + updated_at: user.updated_at.iso8601 + }.compact + end +end + +# app/controllers/concerns/subscription_serialization.rb +module SubscriptionSerialization + def subscription_data(subscription, include_details: false) + base_data = { + id: subscription.id, + status: subscription.status, + plan: plan_data(subscription.plan), + current_period: { + start: subscription.current_period_start&.iso8601, + end: subscription.current_period_end&.iso8601 + }, + created_at: subscription.created_at.iso8601, + updated_at: subscription.updated_at.iso8601 + } + + if include_details + base_data.merge!({ + recent_payments: subscription.payments.recent.limit(3).map { |p| payment_data(p) }, + cancellation: subscription.cancelled? ? { + cancelled_at: subscription.cancelled_at&.iso8601, + reason: subscription.cancellation_reason + } : nil + }) + end + + base_data.compact + end +end +``` + +**Key Serialization Rules** (from platform patterns analysis): +1. **Always include `id`**: Every serialized object must have its UUID +2. **ISO8601 timestamps**: Use `timestamp.iso8601` for all datetime fields +3. **Permissions not roles**: Always include user permissions for access control +4. **Conditional details**: Use `include_details` parameter for nested data +5. **Compact responses**: Remove nil values with `.compact` +6. **Money formatting**: Use `.format` method for currency display + +### 4. API Versioning Strategy (MANDATORY) + +#### Version Management +```ruby +# config/routes.rb +Rails.application.routes.draw do + namespace :api do + # Current version + namespace :v1 do + resources :accounts, only: [:show, :update] + resources :users do + member do + put :change_password + post :verify_email + end + end + resources :subscriptions do + member do + post :cancel + post :reactivate + get :usage + end + resources :payments, only: [:index, :show] + resources :invoices, only: [:index, :show] + end + resources :plans, only: [:index, :show] + resources :payment_methods + + namespace :admin do + resources :accounts, :users, :subscriptions, :analytics + end + end + + # Future version preparation + namespace :v2 do + # New endpoints for v2 + end + end + + # API documentation + get '/api/docs', to: 'api_docs#show' + get '/api/schema', to: 'api_docs#schema' +end + +# Version header handling +class Api::V1::BaseController < ApplicationController + before_action :check_api_version + + private + + def check_api_version + requested_version = request.headers['Accept-Version'] || 'v1' + supported_versions = %w[v1] + + unless supported_versions.include?(requested_version) + render json: { + success: false, + error: "Unsupported API version", + details: { + requested: requested_version, + supported: supported_versions + } + }, status: :not_acceptable + end + end +end +``` + +### 3. Serialization Standards (MANDATORY) + +#### Custom Serializer Implementation +```ruby +# app/serializers/base_serializer.rb +class BaseSerializer + def initialize(object, options = {}) + @object = object + @options = options + end + + def as_json + raise NotImplementedError, "Subclasses must implement #as_json" + end + + def self.serialize(object, options = {}) + new(object, options).as_json + end + + def self.serialize_collection(collection, options = {}) + collection.map { |item| serialize(item, options) } + end + + protected + + def include?(association) + return false unless @options[:include] + @options[:include].include?(association.to_s) || @options[:include].include?(association.to_sym) + end + + def format_timestamp(timestamp) + timestamp&.iso8601 + end + + def format_money(money) + { + amount: money.cents, + formatted: money.format, + currency: money.currency.iso_code + } + end +end + +# app/serializers/subscription_serializer.rb +class SubscriptionSerializer < BaseSerializer + def as_json + base_data = { + id: @object.id, + status: @object.status, + plan: PlanSerializer.serialize(@object.plan), + current_period: { + start: format_timestamp(@object.current_period_start), + end: format_timestamp(@object.current_period_end) + }, + created_at: format_timestamp(@object.created_at), + updated_at: format_timestamp(@object.updated_at) + } + + # Conditional includes + base_data[:account] = AccountSerializer.serialize(@object.account) if include?(:account) + base_data[:payments] = PaymentSerializer.serialize_collection(@object.payments) if include?(:payments) + base_data[:invoices] = InvoiceSerializer.serialize_collection(@object.invoices) if include?(:invoices) + + base_data.compact + end +end + +# app/serializers/plan_serializer.rb +class PlanSerializer < BaseSerializer + def as_json + { + id: @object.id, + name: @object.name, + description: @object.description, + price: format_money(@object.price), + billing_interval: @object.billing_interval, + features: @object.features, + trial_days: @object.trial_days, + created_at: format_timestamp(@object.created_at) + }.tap do |data| + data[:subscription_count] = @object.subscriptions.active.count if include?(:stats) + end + end +end +``` + +### 4. Error Handling Standards (MANDATORY) + +#### Comprehensive Error Handling +```ruby +# app/controllers/concerns/error_handling.rb +module ErrorHandling + extend ActiveSupport::Concern + + included do + rescue_from StandardError, with: :handle_standard_error + rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found + rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error + rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing + rescue_from Pundit::NotAuthorizedError, with: :handle_unauthorized + end + + private + + def handle_standard_error(exception) + Rails.logger.error "API Error: #{exception.class} - #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") + + # Don't expose internal errors in production + error_message = Rails.env.production? ? "Internal server error" : exception.message + + render json: { + success: false, + error: error_message, + error_code: 'INTERNAL_ERROR', + meta: error_meta(exception) + }, status: :internal_server_error + end + + def handle_not_found(exception) + resource_name = extract_resource_name(exception) + + render json: { + success: false, + error: "#{resource_name} not found", + error_code: 'RECORD_NOT_FOUND', + details: { + resource: resource_name, + id: params[:id] + }, + meta: error_meta(exception) + }, status: :not_found + end + + def handle_validation_error(exception) + render json: { + success: false, + error: "Validation failed", + error_code: 'VALIDATION_ERROR', + details: { + field_errors: format_validation_errors(exception.record), + invalid_attributes: exception.record.errors.keys + }, + meta: error_meta(exception) + }, status: :unprocessable_entity + end + + def handle_parameter_missing(exception) + render json: { + success: false, + error: "Required parameter missing", + error_code: 'PARAMETER_MISSING', + details: { + missing_parameter: exception.param, + expected_format: expected_parameter_format(exception.param) + }, + meta: error_meta(exception) + }, status: :bad_request + end + + def handle_unauthorized(exception) + render json: { + success: false, + error: "Insufficient permissions", + error_code: 'UNAUTHORIZED', + details: { + required_permission: exception.policy.class.name, + action: exception.query + }, + meta: error_meta(exception) + }, status: :forbidden + end + + def error_meta(exception) + { + timestamp: Time.current.iso8601, + request_id: request.request_id, + api_version: 'v1', + error_id: SecureRandom.uuid + }.tap do |meta| + meta[:exception_class] = exception.class.name unless Rails.env.production? + end + end + + def extract_resource_name(exception) + # Extract model name from error message + exception.model&.humanize || 'Record' + end + + def format_validation_errors(record) + record.errors.full_messages.map do |message| + field = record.errors.details.find { |_, details| + details.any? { |d| message.include?(d[:error].to_s) } + }&.first + + { + field: field, + message: message, + code: record.errors.details[field]&.first&.dig(:error) + } + end + end + + def expected_parameter_format(param) + case param.to_s + when 'subscription' + { subscription: { plan_id: 'string', payment_method_id: 'string' } } + when 'user' + { user: { email: 'string', password: 'string', first_name: 'string', last_name: 'string' } } + else + "Expected #{param} parameter object" + end + end +end +``` + +### 5. API Documentation Standards (MANDATORY) + +#### OpenAPI/Swagger Integration +```ruby +# app/controllers/api_docs_controller.rb +class ApiDocsController < ApplicationController + skip_before_action :authenticate_request + + def show + render json: openapi_schema + end + + def schema + render json: openapi_schema, content_type: 'application/yaml' + end + + private + + def openapi_schema + @openapi_schema ||= { + openapi: '3.0.0', + info: { + title: 'Powernode API', + version: 'v1', + description: 'Subscription platform API for managing accounts, subscriptions, and billing' + }, + servers: [ + { + url: "#{request.protocol}#{request.host_with_port}/api/v1", + description: Rails.env.humanize + } + ], + security: [ + { bearerAuth: [] } + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + }, + schemas: api_schemas, + responses: common_responses + }, + paths: api_paths + } + end + + def api_schemas + { + Subscription: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + status: { type: 'string', enum: %w[active cancelled suspended] }, + plan: { '$ref': '#/components/schemas/Plan' }, + current_period: { + type: 'object', + properties: { + start: { type: 'string', format: 'date-time' }, + end: { type: 'string', format: 'date-time' } + } + }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' } + }, + required: %w[id status plan current_period created_at updated_at] + }, + Plan: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + description: { type: 'string' }, + price: { + type: 'object', + properties: { + amount: { type: 'integer' }, + formatted: { type: 'string' }, + currency: { type: 'string' } + } + }, + billing_interval: { type: 'string', enum: %w[month year] } + } + }, + Error: { + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + error_code: { type: 'string' }, + details: { type: 'object' }, + meta: { + type: 'object', + properties: { + timestamp: { type: 'string', format: 'date-time' }, + request_id: { type: 'string' }, + api_version: { type: 'string' } + } + } + }, + required: %w[success error error_code meta] + } + } + end + + def api_paths + { + '/subscriptions' => { + get: { + summary: 'List subscriptions', + description: 'Retrieve all subscriptions for the authenticated user\'s account', + parameters: [ + { + name: 'page', + in: 'query', + description: 'Page number for pagination', + schema: { type: 'integer', minimum: 1, default: 1 } + }, + { + name: 'per_page', + in: 'query', + description: 'Number of items per page', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 } + } + ], + responses: { + '200' => { + description: 'Successful response', + content: { + 'application/json' => { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + data: { + type: 'array', + items: { '$ref': '#/components/schemas/Subscription' } + }, + meta: { + type: 'object', + properties: { + pagination: { + type: 'object', + properties: { + current_page: { type: 'integer' }, + total_pages: { type: 'integer' }, + total_count: { type: 'integer' }, + per_page: { type: 'integer' } + } + } + } + } + } + } + } + } + }, + '401' => { '$ref': '#/components/responses/Unauthorized' }, + '500' => { '$ref': '#/components/responses/InternalError' } + } + }, + post: { + summary: 'Create subscription', + description: 'Create a new subscription for the authenticated user\'s account', + requestBody: { + required: true, + content: { + 'application/json' => { + schema: { + type: 'object', + properties: { + subscription: { + type: 'object', + properties: { + plan_id: { type: 'string', format: 'uuid' }, + payment_method_id: { type: 'string', format: 'uuid' } + }, + required: %w[plan_id payment_method_id] + } + } + } + } + } + }, + responses: { + '201' => { + description: 'Subscription created successfully', + content: { + 'application/json' => { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + data: { '$ref': '#/components/schemas/Subscription' }, + message: { type: 'string' } + } + } + } + } + }, + '400' => { '$ref': '#/components/responses/BadRequest' }, + '422' => { '$ref': '#/components/responses/ValidationError' } + } + } + } + } + end + + def common_responses + { + Unauthorized: { + description: 'Authentication required', + content: { + 'application/json' => { + schema: { '$ref': '#/components/schemas/Error' } + } + } + }, + BadRequest: { + description: 'Bad request', + content: { + 'application/json' => { + schema: { '$ref': '#/components/schemas/Error' } + } + } + }, + ValidationError: { + description: 'Validation error', + content: { + 'application/json' => { + schema: { '$ref': '#/components/schemas/Error' } + } + } + }, + InternalError: { + description: 'Internal server error', + content: { + 'application/json' => { + schema: { '$ref': '#/components/schemas/Error' } + } + } + } + } + end +end +``` + +### 6. Performance Optimization (MANDATORY) + +#### Query Optimization +```ruby +# app/controllers/concerns/performance_optimization.rb +module PerformanceOptimization + extend ActiveSupport::Concern + + included do + around_action :measure_performance + end + + private + + def measure_performance + start_time = Time.current + db_queries_start = count_db_queries + + yield + + end_time = Time.current + db_queries_end = count_db_queries + + performance_data = { + duration: ((end_time - start_time) * 1000).round(2), + db_queries: db_queries_end - db_queries_start, + endpoint: "#{request.method} #{request.path}" + } + + # Add performance headers + response.headers['X-Response-Time'] = "#{performance_data[:duration]}ms" + response.headers['X-DB-Queries'] = performance_data[:db_queries].to_s + + # Log slow requests + if performance_data[:duration] > 1000 # 1 second + Rails.logger.warn "Slow API request: #{performance_data}" + end + + # Log excessive DB queries + if performance_data[:db_queries] > 10 + Rails.logger.warn "High DB query count: #{performance_data}" + end + end + + def count_db_queries + ActiveRecord::Base.connection.query_cache.size + end + + def optimize_includes(base_relation) + # Smart includes based on requested fields + includes = [] + + if params[:include]&.include?('plan') + includes << :plan + end + + if params[:include]&.include?('payments') + includes << { payments: :payment_method } + end + + if params[:include]&.include?('invoices') + includes << :invoices + end + + includes.any? ? base_relation.includes(*includes) : base_relation + end +end +``` + +#### Caching Strategy +```ruby +# app/controllers/concerns/api_caching.rb +module ApiCaching + extend ActiveSupport::Concern + + def cache_key_for(object, version = nil) + if object.respond_to?(:cache_key_with_version) + object.cache_key_with_version + else + "#{object.class.name.downcase}/#{object.id}-#{version || object.updated_at.to_i}" + end + end + + def cached_response(cache_key, expires_in: 5.minutes) + Rails.cache.fetch(cache_key, expires_in: expires_in) do + yield + end + end + + def expire_cache_for(object) + pattern = "#{object.class.name.downcase}/#{object.id}*" + Rails.cache.delete_matched(pattern) + end + + # Example usage in controller + def show + cache_key = cache_key_for(@subscription, params[:include]&.sort&.join('-')) + + cached_data = cached_response(cache_key) do + subscription_data(@subscription, include_details: true) + end + + success_response(cached_data) + end +end +``` + +### 7. Security Standards (MANDATORY) + +#### API Security Implementation +```ruby +# app/controllers/concerns/api_security.rb +module ApiSecurity + extend ActiveSupport::Concern + + included do + before_action :validate_content_type + before_action :validate_request_size + before_action :check_rate_limits + after_action :add_security_headers + end + + private + + def validate_content_type + return unless request.post? || request.patch? || request.put? + + unless request.content_type == 'application/json' + render json: { + success: false, + error: 'Invalid content type', + details: { expected: 'application/json', received: request.content_type } + }, status: :unsupported_media_type + end + end + + def validate_request_size + max_size = 1.megabyte + + if request.content_length && request.content_length > max_size + render json: { + success: false, + error: 'Request too large', + details: { max_size: "#{max_size / 1.megabyte}MB" } + }, status: :payload_too_large + end + end + + def check_rate_limits + # Implement rate limiting logic + user_id = current_user&.id || request.remote_ip + rate_limit_key = "api_rate_limit:#{user_id}" + + current_requests = Rails.cache.read(rate_limit_key) || 0 + + if current_requests >= rate_limit_per_hour + render json: { + success: false, + error: 'Rate limit exceeded', + details: { + limit: rate_limit_per_hour, + reset_time: 1.hour.from_now.iso8601 + } + }, status: :too_many_requests + return + end + + Rails.cache.write(rate_limit_key, current_requests + 1, expires_in: 1.hour) + end + + def add_security_headers + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-XSS-Protection'] = '1; mode=block' + end + + def rate_limit_per_hour + current_user&.premium? ? 10000 : 1000 + end +end +``` + +## Development Commands + +### API Development Workflow +```bash +# Generate API controllers +rails generate controller Api::V1::Subscriptions +rails generate controller Api::V1::Payments +rails generate controller Api::V1::Plans + +# Generate serializers +rails generate serializer Subscription +rails generate serializer Payment +rails generate serializer Plan + +# Test API endpoints +curl -X GET http://localhost:3000/api/v1/subscriptions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" + +# Test API with different versions +curl -X GET http://localhost:3000/api/v1/subscriptions \ + -H "Accept-Version: v1" \ + -H "Authorization: Bearer " +``` + +### API Testing +```bash +# Run API integration tests +bundle exec rspec spec/requests/api/v1/ + +# Generate API documentation +rake api:docs:generate + +# Validate API responses +rake api:validate_schemas +``` + +## Integration Points + +### API Developer Coordinates With: +- **Rails Architect**: Controller architecture, routing configuration +- **Data Modeler**: Serialization patterns, query optimization +- **Payment Integration Specialist**: Payment endpoint security +- **Security Specialist**: Authentication, rate limiting, validation +- **Backend Test Engineer**: API endpoint testing, integration tests + +## Quick Reference + +### Controller Template +```ruby +class Api::V1::ResourcesController < Api::V1::BaseController + before_action :set_resource, only: [:show, :update, :destroy] + + def index + resources = optimize_includes(current_user.account.resources) + paginated = paginate_collection(resources) + success_response(serialize_collection(paginated)) + end + + def show + success_response(ResourceSerializer.serialize(@resource, include: params[:include])) + end + + def create + service_result = ResourceCreationService.call(resource_params) + + if service_result.success? + success_response(service_result.data, "Created successfully", :created) + else + error_response(service_result.error, service_result.details, :unprocessable_entity) + end + end + + private + + def set_resource + @resource = current_user.account.resources.find(params[:id]) + end + + def resource_params + params.require(:resource).permit(:name, :description, :status) + end +end +``` + +### Standardized Response Examples + +#### Success Response +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "first_name": "John", + "last_name": "Doe", + "status": "active", + "permissions": ["users.read", "billing.read"] + }, + "message": "User retrieved successfully" +} +``` + +#### Error Response +```json +{ + "success": false, + "error": "Validation failed", + "code": "VALIDATION_ERROR", + "details": [ + "Email can't be blank", + "Password is too short (minimum is 12 characters)" + ] +} +``` + +#### Paginated Response +```json +{ + "success": true, + "data": [ + { "id": "uuid1", "name": "Item 1" }, + { "id": "uuid2", "name": "Item 2" } + ], + "meta": { + "pagination": { + "current_page": 1, + "total_pages": 5, + "total_count": 100, + "per_page": 20 + } + } +} +``` + +**Response Format Validation**: +```bash +# Audit response format compliance +grep -r "render json:" server/app/controllers/ | grep -c '"success":' +grep -r "success: true" server/app/controllers/ | wc -l +grep -r "success: false" server/app/controllers/ | wc -l +``` diff --git a/docs/backend/BAAS_API_REFERENCE.md b/docs/backend/BAAS_API_REFERENCE.md new file mode 100644 index 000000000..d1b7c5abc --- /dev/null +++ b/docs/backend/BAAS_API_REFERENCE.md @@ -0,0 +1,857 @@ +# BaaS API Reference + +**Billing-as-a-Service API for multi-tenant billing** + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Authentication](#authentication) +3. [Tenants API](#tenants-api) +4. [Customers API](#customers-api) +5. [Subscriptions API](#subscriptions-api) +6. [Invoices API](#invoices-api) +7. [Usage API](#usage-api) +8. [Error Handling](#error-handling) + +--- + +## Overview + +The BaaS API enables SaaS platforms to embed billing functionality. It provides multi-tenant subscription management, invoicing, and usage-based billing. + +### Base URL + +``` +/api/v1/baas +``` + +### Features + +- Multi-tenant isolation +- Customer management +- Subscription lifecycle +- Invoice generation and management +- Usage-based metering +- Webhook notifications + +--- + +## Authentication + +All BaaS API requests require API key authentication. + +### Headers + +```http +Authorization: Bearer +X-Tenant-ID: # Optional, derived from API key +``` + +### Scopes + +API keys can be scoped to specific resources: + +| Scope | Access | +|-------|--------| +| `customers` | Customer CRUD operations | +| `subscriptions` | Subscription management | +| `invoices` | Invoice operations | +| `usage` | Usage metering | + +--- + +## Tenants API + +### Get Current Tenant + +```http +GET /api/v1/baas/tenant +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "tenant_abc123", + "name": "Acme Corp", + "slug": "acme", + "tier": "growth", + "environment": "production", + "default_currency": "USD", + "timezone": "America/New_York" + } +} +``` + +### Update Tenant + +```http +PATCH /api/v1/baas/tenant +``` + +**Request Body:** +```json +{ + "name": "Acme Corporation", + "webhook_url": "https://api.acme.com/webhooks/billing", + "webhook_secret": "whsec_...", + "default_currency": "USD", + "timezone": "America/New_York", + "branding": { + "logo_url": "https://...", + "primary_color": "#1a73e8" + }, + "metadata": { + "external_id": "acme_123" + } +} +``` + +### Get Dashboard Stats + +```http +GET /api/v1/baas/tenant/dashboard +``` + +**Response:** +```json +{ + "success": true, + "data": { + "total_customers": 150, + "active_subscriptions": 142, + "mrr": 15000.00, + "arr": 180000.00, + "churn_rate": 2.5, + "growth_rate": 8.3 + } +} +``` + +### Get Rate Limits + +```http +GET /api/v1/baas/tenant/limits +``` + +**Response:** +```json +{ + "success": true, + "data": { + "api_calls": { "used": 5000, "limit": 10000, "reset_at": "2025-02-01T00:00:00Z" }, + "customers": { "used": 150, "limit": 1000 }, + "subscriptions": { "used": 142, "limit": 1000 } + } +} +``` + +### Get Billing Configuration + +```http +GET /api/v1/baas/tenant/billing_configuration +``` + +**Response:** +```json +{ + "success": true, + "data": { + "invoice_prefix": "INV", + "invoice_due_days": 30, + "auto_invoice": true, + "auto_charge": true, + "tax_enabled": true, + "tax_provider": "stripe_tax", + "dunning_enabled": true, + "dunning_attempts": 3, + "dunning_interval_days": 7, + "usage_billing_enabled": true, + "trial_enabled": true, + "default_trial_days": 14 + } +} +``` + +### Update Billing Configuration + +```http +PATCH /api/v1/baas/tenant/billing_configuration +``` + +--- + +## Customers API + +### List Customers + +```http +GET /api/v1/baas/customers +``` + +**Query Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `status` | string | Filter by status | +| `email` | string | Filter by email | +| `page` | integer | Page number | +| `per_page` | integer | Items per page (max: 100) | + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": "cus_abc123", + "external_id": "user_456", + "email": "john@example.com", + "name": "John Doe", + "status": "active", + "currency": "USD", + "created_at": "2025-01-15T10:30:00Z" + } + ], + "meta": { + "pagination": { + "current_page": 1, + "total_pages": 5, + "total_count": 150, + "per_page": 30 + } + } +} +``` + +### Get Customer + +```http +GET /api/v1/baas/customers/:id +``` + +### Create Customer + +```http +POST /api/v1/baas/customers +``` + +**Request Body:** +```json +{ + "external_id": "user_456", + "email": "john@example.com", + "name": "John Doe", + "address_line1": "123 Main St", + "address_line2": "Suite 100", + "city": "San Francisco", + "state": "CA", + "postal_code": "94102", + "country": "US", + "tax_id": "XX-XXXXXXX", + "tax_id_type": "us_ein", + "tax_exempt": false, + "currency": "USD", + "metadata": { + "plan_type": "business" + } +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "cus_abc123", + "external_id": "user_456", + "email": "john@example.com", + "name": "John Doe", + "status": "active", + "created_at": "2025-01-30T10:30:00Z" + } +} +``` + +### Update Customer + +```http +PATCH /api/v1/baas/customers/:id +``` + +### Delete Customer + +```http +DELETE /api/v1/baas/customers/:id +``` + +**Note:** Cannot delete customers with active subscriptions. Archives the customer instead. + +--- + +## Subscriptions API + +### List Subscriptions + +```http +GET /api/v1/baas/subscriptions +``` + +**Query Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `status` | string | active, canceled, paused, past_due | +| `customer_id` | string | Filter by customer | +| `page` | integer | Page number | +| `per_page` | integer | Items per page | + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": "sub_abc123", + "customer_id": "cus_xyz789", + "plan_id": "plan_growth", + "status": "active", + "billing_interval": "month", + "unit_amount": 9900, + "currency": "USD", + "quantity": 5, + "current_period_start": "2025-01-01T00:00:00Z", + "current_period_end": "2025-02-01T00:00:00Z", + "created_at": "2025-01-01T10:30:00Z" + } + ], + "meta": { + "pagination": { ... } + } +} +``` + +### Get Subscription + +```http +GET /api/v1/baas/subscriptions/:id +``` + +### Create Subscription + +```http +POST /api/v1/baas/subscriptions +``` + +**Request Body:** +```json +{ + "customer_id": "cus_xyz789", + "external_id": "sub_external_123", + "plan_id": "plan_growth", + "billing_interval": "month", + "billing_interval_count": 1, + "unit_amount": 9900, + "currency": "USD", + "quantity": 5, + "trial_days": 14, + "metadata": { + "source": "api" + } +} +``` + +### Update Subscription + +```http +PATCH /api/v1/baas/subscriptions/:id +``` + +### Cancel Subscription + +```http +POST /api/v1/baas/subscriptions/:id/cancel +``` + +**Request Body:** +```json +{ + "reason": "Customer requested", + "at_period_end": true +} +``` + +### Pause Subscription + +```http +POST /api/v1/baas/subscriptions/:id/pause +``` + +### Resume Subscription + +```http +POST /api/v1/baas/subscriptions/:id/resume +``` + +--- + +## Invoices API + +### List Invoices + +```http +GET /api/v1/baas/invoices +``` + +**Query Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `status` | string | draft, open, paid, void, uncollectible | +| `customer_id` | string | Filter by customer | +| `page` | integer | Page number | +| `per_page` | integer | Items per page | + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": "inv_abc123", + "number": "INV-2025-0042", + "customer_id": "cus_xyz789", + "subscription_id": "sub_abc123", + "status": "open", + "currency": "USD", + "subtotal": 9900, + "tax": 990, + "total": 10890, + "amount_due": 10890, + "amount_paid": 0, + "due_date": "2025-02-15", + "period_start": "2025-01-01", + "period_end": "2025-02-01", + "created_at": "2025-01-01T10:30:00Z" + } + ], + "meta": { + "pagination": { ... } + } +} +``` + +### Get Invoice + +```http +GET /api/v1/baas/invoices/:id +``` + +### Create Invoice + +```http +POST /api/v1/baas/invoices +``` + +**Request Body:** +```json +{ + "customer_id": "cus_xyz789", + "subscription_id": "sub_abc123", + "external_id": "inv_external_123", + "currency": "USD", + "due_date": "2025-02-15", + "period_start": "2025-01-01", + "period_end": "2025-02-01", + "line_items": [ + { + "description": "Growth Plan - 5 seats", + "amount_cents": 9900, + "quantity": 1, + "metadata": {} + } + ], + "metadata": {} +} +``` + +### Update Invoice + +```http +PATCH /api/v1/baas/invoices/:id +``` + +**Note:** Only draft invoices can be updated. + +### Delete Invoice + +```http +DELETE /api/v1/baas/invoices/:id +``` + +**Note:** Only draft invoices can be deleted. + +### Finalize Invoice + +```http +POST /api/v1/baas/invoices/:id/finalize +``` + +Transitions invoice from draft to open and assigns an invoice number. + +### Pay Invoice + +```http +POST /api/v1/baas/invoices/:id/pay +``` + +**Request Body:** +```json +{ + "payment_reference": "pi_abc123" +} +``` + +### Void Invoice + +```http +POST /api/v1/baas/invoices/:id/void +``` + +**Request Body:** +```json +{ + "reason": "Customer requested credit" +} +``` + +### Add Line Item + +```http +POST /api/v1/baas/invoices/:id/line_items +``` + +**Request Body:** +```json +{ + "description": "Additional API calls", + "amount_cents": 500, + "quantity": 1, + "metadata": {} +} +``` + +### Remove Line Item + +```http +DELETE /api/v1/baas/invoices/:id/line_items/:item_id +``` + +--- + +## Usage API + +### Record Usage Event + +```http +POST /api/v1/baas/usage_events +``` + +**Request Body:** +```json +{ + "customer_id": "cus_xyz789", + "subscription_id": "sub_abc123", + "meter_id": "api_calls", + "idempotency_key": "evt_abc123", + "quantity": 100, + "timestamp": "2025-01-30T10:30:00Z", + "billing_period_start": "2025-01-01", + "billing_period_end": "2025-02-01", + "properties": { + "endpoint": "/api/users", + "method": "GET" + }, + "metadata": {} +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "ue_abc123", + "customer_id": "cus_xyz789", + "meter_id": "api_calls", + "quantity": 100, + "status": "pending", + "created_at": "2025-01-30T10:30:00Z" + } +} +``` + +### Batch Record Usage + +```http +POST /api/v1/baas/usage_events/batch +``` + +**Request Body:** +```json +{ + "events": [ + { + "customer_id": "cus_xyz789", + "meter_id": "api_calls", + "idempotency_key": "evt_001", + "quantity": 50 + }, + { + "customer_id": "cus_xyz789", + "meter_id": "storage", + "idempotency_key": "evt_002", + "quantity": 1024 + } + ] +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "successful": 2, + "failed": 0, + "errors": [] + } +} +``` + +**Note:** Maximum 1000 events per batch. + +### List Usage Records + +```http +GET /api/v1/baas/usage +``` + +**Query Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `customer_id` | string | Filter by customer | +| `meter_id` | string | Filter by meter | +| `status` | string | pending, processed, billed | +| `start_date` | datetime | Start date (ISO8601) | +| `end_date` | datetime | End date (ISO8601) | +| `page` | integer | Page number | +| `per_page` | integer | Items per page | + +### Get Usage Summary + +```http +GET /api/v1/baas/usage/summary +``` + +**Required Parameters:** +- `customer_id`: Customer ID + +**Optional Parameters:** +- `start_date`: Period start +- `end_date`: Period end + +**Response:** +```json +{ + "success": true, + "data": { + "customer_id": "cus_xyz789", + "period": { + "start": "2025-01-01", + "end": "2025-01-31" + }, + "meters": [ + { + "meter_id": "api_calls", + "total_quantity": 15000, + "billable_amount": 1500 + }, + { + "meter_id": "storage", + "total_quantity": 102400, + "billable_amount": 5120 + } + ], + "total_billable": 6620 + } +} +``` + +### Get Aggregated Usage + +```http +GET /api/v1/baas/usage/aggregate +``` + +**Required Parameters:** +- `customer_id`: Customer ID +- `meter_id`: Meter ID + +**Optional Parameters:** +- `start_date`: Period start +- `end_date`: Period end + +### Get Usage Analytics + +```http +GET /api/v1/baas/usage/analytics +``` + +**Optional Parameters:** +- `start_date`: Period start (default: 30 days ago) +- `end_date`: Period end (default: today) + +**Response:** +```json +{ + "success": true, + "data": { + "period": { + "start": "2025-01-01", + "end": "2025-01-30" + }, + "total_events": 50000, + "total_customers": 150, + "meters": [ + { + "meter_id": "api_calls", + "total_quantity": 1500000, + "unique_customers": 145, + "daily_average": 50000 + } + ], + "trends": { + "daily": [ ... ], + "weekly": [ ... ] + } + } +} +``` + +--- + +## Error Handling + +### Error Response Format + +```json +{ + "success": false, + "error": "Error message description" +} +``` + +Or with multiple errors: + +```json +{ + "success": false, + "errors": [ + "Field1 is required", + "Field2 must be a valid email" + ] +} +``` + +### HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 204 | No Content (successful delete) | +| 207 | Multi-Status (batch with partial failures) | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden (insufficient scope) | +| 404 | Not Found | +| 422 | Unprocessable Entity | +| 429 | Rate Limited | +| 500 | Internal Server Error | + +### Common Errors + +| Error | Description | +|-------|-------------| +| `Unauthorized` | Invalid or missing API key | +| `Forbidden` | API key lacks required scope | +| `Customer not found` | Invalid customer ID | +| `Subscription not found` | Invalid subscription ID | +| `Invoice not found` | Invalid invoice ID | +| `Cannot delete customer with active subscriptions` | Customer has active subs | +| `Cannot update non-draft invoice` | Invoice already finalized | +| `Maximum 1000 events per batch` | Batch too large | + +--- + +## Rate Limits + +| Tier | Requests/Minute | Burst | +|------|-----------------|-------| +| Starter | 100 | 20 | +| Growth | 1000 | 100 | +| Business | 10000 | 1000 | + +Rate limit headers: +```http +X-RateLimit-Limit: 1000 +X-RateLimit-Remaining: 950 +X-RateLimit-Reset: 1706619600 +``` + +--- + +## Webhooks + +BaaS sends webhooks for billing events: + +| Event | Description | +|-------|-------------| +| `customer.created` | New customer created | +| `customer.updated` | Customer updated | +| `subscription.created` | New subscription | +| `subscription.updated` | Subscription changed | +| `subscription.canceled` | Subscription canceled | +| `invoice.created` | Invoice generated | +| `invoice.finalized` | Invoice ready for payment | +| `invoice.paid` | Invoice payment received | +| `invoice.past_due` | Invoice past due date | +| `usage.threshold_reached` | Usage limit warning | + +### Webhook Payload + +```json +{ + "id": "evt_abc123", + "type": "subscription.created", + "created_at": "2025-01-30T10:30:00Z", + "data": { + "object": { ... } + } +} +``` + +### Webhook Verification + +Verify webhooks using the signature header: + +```http +X-Webhook-Signature: sha256=abc123... +``` + +--- + +**Document Status**: Complete +**Last Updated**: 2025-01-30 +**Source**: `server/app/controllers/api/v1/baas/` diff --git a/docs/backend/BACKEND_SERVICE_ARCHITECTURE.md b/docs/backend/BACKEND_SERVICE_ARCHITECTURE.md new file mode 100644 index 000000000..144f598fb --- /dev/null +++ b/docs/backend/BACKEND_SERVICE_ARCHITECTURE.md @@ -0,0 +1,668 @@ +# Backend Service Architecture + +**Comprehensive guide to the Rails service layer architecture** + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Service Domains](#service-domains) +3. [AI Services](#ai-services) +4. [MCP Services](#mcp-services) +5. [Billing Services](#billing-services) +6. [BaaS Services](#baas-services) +7. [DevOps Services](#devops-services) +8. [Infrastructure Services](#infrastructure-services) +9. [Service Patterns](#service-patterns) + +--- + +## Overview + +The Powernode backend uses a service-oriented architecture with services organized in `server/app/services/`. Services encapsulate business logic and are called from controllers, jobs, and other services. + +### Directory Structure (634 service files across 22+ namespaces) + +``` +server/app/services/ +├── ai/ # AI orchestration, agents, providers (317 files) +├── mcp/ # Model Context Protocol services (101 files) +├── devops/ # CI/CD and deployment services (38 files) +├── a2a/ # Agent-to-Agent protocol (17 files) +├── chat/ # Conversation management (10 files) +├── security/ # Auth, encryption, security (11 files) +├── orchestration/ # Workflow orchestration (8 files) +├── cost_optimization/ # Cost tracking and optimization (7 files) +├── storage_providers/ # Storage backend implementations (7 files) +├── concerns/ # Shared service concerns (7 files) +├── provider_testing/ # Provider health checks (6 files) +├── shared/ # Cross-cutting utilities (4 files) +├── billing/ # Payment and subscription services (2 files) +├── data_management/ # Data sanitization and management (2 files) +├── monitoring/ # Health monitoring (2 files) +├── permissions/ # Permission management (2 files) +├── rate_limiting/ # Request rate limiting (2 files) +├── audit/ # Audit log services (2 files) +├── admin/ # Admin panel services (2 files) +├── auth/ # Authentication services (1 file) +├── accounts/ # Account management (1 file) +├── analytics/ # Analytics processing +├── baas/ # Billing-as-a-Service API services +├── notifications/ # Notification delivery +└── services/ # Service management +``` + +--- + +## Service Domains + +### Quick Reference + +| Domain | Service Count | Primary Responsibility | +|--------|---------------|------------------------| +| AI | 317 | Agent execution, provider management, workflows, knowledge, memory | +| MCP | 101 | Node executors, orchestration, protocol handling | +| DevOps | 38 | CI/CD, Git, deployment, registry | +| A2A | 17 | Agent-to-Agent protocol | +| Security | 11 | Authentication, authorization, encryption | +| Chat | 10 | Conversation management, context building | +| Orchestration | 8 | Workflow orchestration coordination | +| Cost Optimization | 7 | Cost tracking, optimization, budgets | +| Storage | 7 | S3, GCS, NFS, SMB, local | +| Provider Testing | 6 | Connection testing, health checks | +| Billing | 2 | Subscriptions, payments | +| Others | 25+ | Admin, audit, monitoring, permissions, rate limiting | + +--- + +## AI Services + +Located in `server/app/services/ai/`. Handles AI agent orchestration, provider management, and workflow execution. + +### Core Services + +#### AgentOrchestrationService + +**File**: `agent_orchestration_service.rb` + +Primary service for executing AI agents with full orchestration support. + +```ruby +class Ai::AgentOrchestrationService + def initialize(agent:, account:, user: nil) + def execute(input_parameters) + def execute_with_streaming(input_parameters, &block) +end +``` + +**Responsibilities**: +- Agent execution lifecycle management +- Provider selection and fallback +- Token tracking and cost calculation +- Streaming response handling + +#### McpAgentExecutor + +**File**: `mcp_agent_executor.rb` + +Executes agents through the MCP protocol. + +```ruby +class Ai::McpAgentExecutor + def initialize(agent:, execution:, account:) + def execute(input_parameters) +end +``` + +### Provider Management + +#### ProviderLoadBalancerService + +**File**: `provider_load_balancer_service.rb` + +Intelligent load balancing across AI providers. + +```ruby +class Ai::ProviderLoadBalancerService + LOAD_BALANCING_STRATEGIES = %w[ + round_robin + weighted_round_robin + least_connections + cost_optimized + performance_based + ].freeze + + def initialize(account, capability: "text_generation", strategy: "cost_optimized") + def select_provider(request_metadata = {}) + def execute_with_fallback(request_type, **options, &block) + def load_balancing_stats +end +``` + +**Strategies**: +- `round_robin`: Simple rotation +- `weighted_round_robin`: Performance-weighted rotation +- `least_connections`: Route to least loaded +- `cost_optimized`: Minimize cost per request +- `performance_based`: Optimize for speed + +#### ProviderCircuitBreakerService + +**File**: `provider_circuit_breaker_service.rb` + +Circuit breaker pattern for provider resilience. + +```ruby +class Ai::ProviderCircuitBreakerService + def initialize(provider) + def provider_available? + def record_success + def record_failure(error) + def circuit_state # :closed, :open, :half_open +end +``` + +#### ProviderTestService + +**File**: `provider_test_service.rb` + +Tests provider connectivity and capabilities. + +### Workflow Services + +#### WorkflowValidationService + +**File**: `workflow_validation_service.rb` + +Validates workflow structure and configuration. + +```ruby +class Ai::WorkflowValidationService + def initialize(workflow) + def validate + def validate_node(node) + def validate_edges +end +``` + +#### WorkflowRecoveryService + +**File**: `workflow_recovery_service.rb` + +Handles workflow failure recovery. + +#### WorkflowCheckpointRecoveryService + +**File**: `workflow_checkpoint_recovery_service.rb` + +Checkpoint-based recovery for long-running workflows. + +#### WorkflowCircuitBreakerService + +**File**: `workflow_circuit_breaker_service.rb` + +Circuit breaker for workflow execution. + +#### WorkflowRetryStrategyService + +**File**: `workflow_retry_strategy_service.rb` + +Configurable retry strategies for workflows. + +### Node Validators + +Located in `server/app/services/ai/workflow_validators/`. + +| Validator | Node Type | Purpose | +|-----------|-----------|---------| +| `BaseValidator` | All | Base validation logic | +| `AiAgentValidator` | ai_agent | Validates agent configuration | +| `ApiCallValidator` | api_call | Validates API call config | +| `ConditionValidator` | condition | Validates condition expressions | +| `DelayValidator` | delay | Validates delay configuration | +| `HumanApprovalValidator` | human_approval | Validates approval setup | +| `LoopValidator` | loop | Validates loop configuration | +| `SubWorkflowValidator` | sub_workflow | Validates nested workflows | +| `TransformValidator` | transform | Validates transform rules | +| `WebhookValidator` | webhook | Validates webhook config | + +### Support Services + +| Service | Purpose | +|---------|---------| +| `AnalyticsInsightsService` | AI usage analytics | +| `CostOptimizationService` | AI cost optimization | +| `CredentialEncryptionService` | Secure credential storage | +| `DebuggingService` | AI execution debugging | +| `ErrorRecoveryService` | Error handling strategies | + +--- + +## MCP Services + +Located in `server/app/services/mcp/`. Implements the Model Context Protocol for workflow execution. + +### Core Components + +#### AiWorkflowOrchestrator + +Primary orchestrator for workflow execution (not in mcp/ but central to MCP). + +#### Orchestrator Modules + +Located in `server/app/services/mcp/orchestrator/`. + +| Module | Purpose | +|--------|---------| +| `Validation` | Pre-execution validation | +| `Compensation` | Rollback on failure | + +#### Node Executors + +See [NODE_EXECUTOR_REFERENCE.md](NODE_EXECUTOR_REFERENCE.md) for complete documentation. + +50 node executor classes in `server/app/services/mcp/node_executors/`: +- Control flow: start, end, condition, loop, split, merge, delay, scheduler +- AI: ai_agent, sub_workflow +- Integration: api_call, webhook, notification, email, database, file operations +- Content: page and KB article CRUD +- DevOps: CI/CD, Git operations, deployment +- MCP: tool, prompt, resource execution + +### Support Services + +| Service | Purpose | +|---------|---------| +| `ConditionalEvaluator` | Evaluates conditional expressions | +| `ExecutionTracer` | Execution tracing and debugging | + +--- + +## Billing Services + +Located in `server/app/services/billing/`. Handles subscription lifecycle, payments, and usage. + +### SubscriptionService + +**File**: `subscription_service.rb` + +Core subscription lifecycle management. + +```ruby +class Billing::SubscriptionService + def initialize(subscription) + def create(plan:, payment_method:) + def update(params) + def cancel(reason: nil, at_period_end: false) + def pause + def resume + def change_plan(new_plan:, prorate: true) +end +``` + +### PaymentProcessingService + +**File**: `payment_processing_service.rb` + +Processes payments through configured providers. + +```ruby +class Billing::PaymentProcessingService + def initialize(account) + def process_payment(amount:, currency:, payment_method:) + def refund(payment_id, amount: nil) +end +``` + +### FeaturePlanService + +**File**: `feature_plan_service.rb` + +Manages plan features and entitlements. + +```ruby +class Billing::FeaturePlanService + def initialize(plan) + def feature_enabled?(feature_key) + def feature_limit(feature_key) + def compare_plans(other_plan) +end +``` + +### UsageLimitService + +**File**: `usage_limit_service.rb` + +Tracks and enforces usage limits. + +```ruby +class Billing::UsageLimitService + def initialize(subscription) + def check_limit(feature_key, quantity: 1) + def record_usage(feature_key, quantity: 1) + def reset_usage(feature_key) + def usage_summary +end +``` + +### SubscriptionBroadcastService + +**File**: `subscription_broadcast_service.rb` + +Broadcasts subscription changes via ActionCable. + +### PayPalService + +**File**: `paypal_service.rb` + +PayPal payment integration. + +--- + +## BaaS Services + +Located in `server/app/services/baas/`. Implements Billing-as-a-Service for multi-tenant billing. + +### TenantService + +**File**: `tenant_service.rb` (inferred from controller) + +Manages BaaS tenants. + +```ruby +class BaaS::TenantService + def initialize(account: nil, tenant: nil) + def create_tenant(params) + def update_tenant(params) + def dashboard_stats + def check_rate_limits +end +``` + +### BillingApiService + +**File**: `billing_api_service.rb` (inferred from controller) + +Core BaaS billing operations. + +```ruby +class BaaS::BillingApiService + def initialize(tenant:) + + # Customers + def list_customers(status:, email:, page:, per_page:) + def get_customer(id) + def create_customer(params) + def update_customer(id, params) + + # Subscriptions + def list_subscriptions(status:, customer_id:, page:, per_page:) + def get_subscription(id) + def create_subscription(params) + def update_subscription(id, params) + def cancel_subscription(id, params) + + # Invoices + def list_invoices(status:, customer_id:, page:, per_page:) + def get_invoice(id) + def create_invoice(params) + def finalize_invoice(id) + def pay_invoice(id, params) + def void_invoice(id, params) +end +``` + +### UsageMeteringService + +**File**: `usage_metering_service.rb` (inferred from controller) + +Usage-based billing metering. + +```ruby +class BaaS::UsageMeteringService + def initialize(tenant:) + def record_usage(params) + def record_batch(events) + def list_records(customer_id:, meter_id:, status:, start_date:, end_date:, page:, per_page:) + def customer_usage_summary(customer_id:, start_date:, end_date:) + def get_usage(customer_id:, meter_id:, start_date:, end_date:) + def analytics(start_date:, end_date:) +end +``` + +--- + +## DevOps Services + +Located in `server/app/services/devops/`. Handles CI/CD, Git operations, and deployments. + +### Core Services + +| Service | Purpose | +|---------|---------| +| `BaseExecutor` | Base class for executors | +| `ExecutionService` | Orchestrates DevOps executions | +| `ProviderClient` | Provider API client | + +### Execution Services + +| Service | Purpose | +|---------|---------| +| `GithubActionExecutor` | GitHub Actions integration | +| `McpServerExecutor` | MCP server execution | +| `RestApiExecutor` | REST API calls | +| `WebhookExecutor` | Webhook handling | + +### Git Services + +Located in `server/app/services/devops/git/`. + +| Service | Purpose | +|---------|---------| +| `CredentialEncryptionService` | Git credential encryption | +| `OAuthService` | Git OAuth integration | + +### Support Services + +| Service | Purpose | +|---------|---------| +| `CredentialEncryptionService` | DevOps credential security | +| `PromptRenderer` | Template rendering | +| `RegistryService` | Container registry ops | +| `WorkflowGenerator` | CI workflow generation | + +--- + +## Infrastructure Services + +### Cost Optimization + +Located in `server/app/services/cost_optimization/`. + +| Service | Purpose | +|---------|---------| +| `BudgetManagement` | Budget tracking and alerts | +| `CostAnalysis` | Cost analysis and reporting | +| `CostTracking` | Real-time cost tracking | +| `ProviderOptimization` | Provider cost optimization | +| `Recommendations` | Cost reduction suggestions | +| `UsagePatterns` | Usage pattern analysis | + +### Storage Providers + +Located in `server/app/services/storage_providers/`. + +| Service | Backend | +|---------|---------| +| `S3Storage` | AWS S3 | +| `GcsStorage` | Google Cloud Storage | +| `LocalStorage` | Local filesystem | +| `NfsStorage` | NFS mounts | +| `SmbStorage` | SMB/CIFS shares | + +### Provider Testing + +Located in `server/app/services/provider_testing/`. + +| Service | Purpose | +|---------|---------| +| `ConnectionTesting` | Test provider connections | +| `HealthChecks` | Provider health monitoring | +| `LoadTesting` | Load/stress testing | +| `Reporting` | Test result reporting | + +### Other Infrastructure Services + +| Service | Purpose | +|---------|---------| +| `FileStorageService` | File storage abstraction | +| `WebhookHealthService` | Webhook health monitoring | +| `CorsConfigurationService` | CORS configuration | +| `ConsentManagementService` | GDPR consent management | +| `SensitiveDataSanitizer` | PII data sanitization | +| `SettingsUpdateService` | Settings management | +| `PdfReportService` | PDF report generation | +| `JsonSchemaValidator` | JSON schema validation | +| `PermissionSeeder` | Permission data seeding | +| `PageService` | CMS page management | + +--- + +## Service Patterns + +### Standard Service Structure + +```ruby +# frozen_string_literal: true + +class DomainName::ServiceName + def initialize(required_dependency:, optional_dependency: nil) + @required_dependency = required_dependency + @optional_dependency = optional_dependency + @logger = Rails.logger + end + + def primary_action(params) + validate_params!(params) + result = perform_action(params) + { success: true, data: result } + rescue StandardError => e + @logger.error "#{self.class.name} error: #{e.message}" + { success: false, error: e.message } + end + + private + + def validate_params!(params) + raise ArgumentError, "Required param missing" unless params[:required] + end + + def perform_action(params) + # Implementation + end +end +``` + +### Return Value Convention + +All services should return a hash with: + +```ruby +# Success +{ success: true, data: result_data } +{ success: true, data: result_data, meta: { pagination: ... } } + +# Failure +{ success: false, error: "Error message" } +{ success: false, errors: ["Error 1", "Error 2"] } +``` + +### Service Concerns + +Located in `server/app/services/concerns/`. + +| Concern | Purpose | +|---------|---------| +| `AiNodeExecutors` | AI node execution helpers | +| `AiOrchestrationBroadcasting` | ActionCable broadcasting | +| `AiMonitoringConcern` | AI monitoring helpers | +| `AiWorkflowService` | Workflow service helpers | +| `BaseAiService` | Base AI service functionality | +| `CircuitBreakerCore` | Circuit breaker implementation | + +### Using Concerns + +```ruby +class MyService + include Concerns::CircuitBreakerCore + + def execute + with_circuit_breaker do + # Protected operation + end + end +end +``` + +--- + +## Best Practices + +### 1. Keep Services Focused + +Each service should have a single responsibility: +- **Good**: `PaymentProcessingService` handles payments only +- **Bad**: `UserService` that handles auth, profiles, and preferences + +### 2. Use Dependency Injection + +```ruby +def initialize(account:, payment_processor: nil) + @account = account + @payment_processor = payment_processor || Billing::PaymentProcessingService.new(account) +end +``` + +### 3. Handle Errors Gracefully + +```ruby +def execute + result = perform_operation + { success: true, data: result } +rescue SpecificError => e + { success: false, error: e.message, error_code: "SPECIFIC_ERROR" } +rescue StandardError => e + Rails.logger.error "Unexpected error: #{e.message}" + { success: false, error: "An unexpected error occurred" } +end +``` + +### 4. Log Appropriately + +```ruby +@logger.info "Starting operation for account #{@account.id}" +@logger.debug "Processing with params: #{params.inspect}" +@logger.warn "Rate limit approaching for #{@account.id}" +@logger.error "Operation failed: #{error.message}" +``` + +### 5. Use Transactions + +```ruby +def create_with_dependencies + ActiveRecord::Base.transaction do + primary = create_primary_record + create_dependent_records(primary) + { success: true, data: primary } + end +rescue ActiveRecord::RecordInvalid => e + { success: false, errors: e.record.errors.full_messages } +end +``` + +--- + +**Document Status**: Complete +**Last Updated**: 2026-02-26 +**Source**: `server/app/services/` (634 files) diff --git a/docs/backend/BACKGROUND_JOB_ENGINEER_SPECIALIST.md b/docs/backend/BACKGROUND_JOB_ENGINEER_SPECIALIST.md new file mode 100644 index 000000000..7f76d68d5 --- /dev/null +++ b/docs/backend/BACKGROUND_JOB_ENGINEER_SPECIALIST.md @@ -0,0 +1,1403 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# Background Job Engineer Specialist Guide + +## Role & Responsibilities + +The Background Job Engineer specializes in Sidekiq background processing, job scheduling, queue management, and worker service coordination for Powernode's subscription platform. + +### Core Responsibilities +- Setting up Sidekiq for background processing +- Creating scheduled jobs for renewals and billing +- Implementing job retry and failure handling +- Monitoring job performance and queues +- Handling job prioritization and queue management + +### Key Focus Areas +- Reliable job processing and retry mechanisms +- Queue optimization and performance monitoring +- Worker service integration and delegation +- Automated scheduling and recurring tasks +- Job failure handling and alerting + +## Background Job Architecture Standards + +### 1. Sidekiq Configuration (MANDATORY) + +#### Sidekiq Setup and Configuration +```ruby +# config/initializers/sidekiq.rb +require 'sidekiq/web' +require 'sidekiq-cron' + +Sidekiq.configure_server do |config| + config.redis = { + url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'), + network_timeout: 5, + pool_timeout: 5 + } + + # Queue configuration with priorities + config.queues = %w[critical high default low batch] + + # Enable cron jobs + config.on(:startup) do + Sidekiq::Cron::Job.load_from_hash(cron_jobs_config) + end + + # Job lifecycle callbacks + config.server_middleware do |chain| + chain.add JobMetricsMiddleware + chain.add JobAuditMiddleware + chain.add ErrorNotificationMiddleware + end +end + +Sidekiq.configure_client do |config| + config.redis = { + url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'), + network_timeout: 5, + pool_timeout: 5 + } +end + +# Sidekiq Web UI configuration +Sidekiq::Web.use(Rack::Auth::Basic) do |user, password| + [user, password] == [ENV['SIDEKIQ_USERNAME'], ENV['SIDEKIQ_PASSWORD']] +end + +# Dead job retention +Sidekiq.configure_server do |config| + config.death_handlers << ->(job, ex) do + JobFailureNotificationService.call( + job_class: job['class'], + job_args: job['args'], + error: ex.message, + retry_count: job['retry_count'] + ) + end +end + +def cron_jobs_config + { + 'subscription_renewals' => { + 'cron' => '0 9 * * *', # Daily at 9 AM + 'class' => 'SubscriptionRenewalSchedulerJob', + 'queue' => 'high' + }, + 'billing_cleanup' => { + 'cron' => '0 2 * * *', # Daily at 2 AM + 'class' => 'BillingCleanupJob', + 'queue' => 'low' + }, + 'dunning_process' => { + 'cron' => '0 10 * * *', # Daily at 10 AM + 'class' => 'DunningProcessSchedulerJob', + 'queue' => 'high' + }, + 'analytics_aggregation' => { + 'cron' => '0 1 * * *', # Daily at 1 AM + 'class' => 'AnalyticsAggregationJob', + 'queue' => 'default' + }, + 'webhook_retry' => { + 'cron' => '*/15 * * * *', # Every 15 minutes + 'class' => 'WebhookRetryJob', + 'queue' => 'default' + }, + 'system_health_check' => { + 'cron' => '*/5 * * * *', # Every 5 minutes + 'class' => 'SystemHealthCheckJob', + 'queue' => 'low' + } + } +end +``` + +#### Queue Configuration and Routing +```ruby +# config/sidekiq.yml +--- +:queues: + - [critical, 10] + - [high, 5] + - [default, 3] + - [low, 1] + - [batch, 1] + +:max_retries: 3 +:timeout: 30 + +:scheduler: + :enabled: true + :dynamic: true + +production: + :concurrency: 20 + :queues: + - [critical, 15] + - [high, 10] + - [default, 5] + - [low, 2] + - [batch, 1] + +development: + :concurrency: 5 + :queues: + - [critical, 5] + - [high, 3] + - [default, 2] + - [low, 1] +``` + +### 2. Standard BaseJob Pattern (CRITICAL) + +#### Discovered BaseJob Implementation +**MANDATORY**: All worker jobs must inherit from the standardized BaseJob pattern discovered in platform analysis. + +```ruby +# app/jobs/base_job.rb - Actual platform implementation +require 'sidekiq' + +class BaseJob + include Sidekiq::Job + + # Common job configuration + sidekiq_options retry: 3, + dead: true, + queue: 'default' + + # Exponential backoff retry strategy with API error handling + sidekiq_retry_in do |count, exception| + case exception + when BackendApiClient::ApiError + # API errors get shorter retry intervals + [30, 60, 180][count - 1] || 300 + else + # Other errors use exponential backoff + (count ** 4) + 15 + (rand(30) * (count + 1)) + end + end + + def perform(*args) + @started_at = Time.current + logger.info "Starting #{self.class.name} with args: #{args.inspect}" + + execute(*args) + + @finished_at = Time.current + duration = @finished_at - @started_at + logger.info "Completed #{self.class.name} in #{duration.round(2)}s" + rescue StandardError => e + @finished_at = Time.current + duration = @finished_at - @started_at + logger.error "Failed #{self.class.name} after #{duration.round(2)}s: #{e.message}" + logger.error e.backtrace.join("\n") if logger.level <= Logger::DEBUG + raise + end + + protected + + # Override this method in subclasses to implement job logic + def execute(*args) + raise NotImplementedError, "Subclasses must implement the execute method" + end + + # API client for backend communication + def api_client + @api_client ||= BackendApiClient.new + end + + # Logger instance + def logger + PowernodeWorker.application.logger + end + + # Helper to safely parse JSON + def safe_parse_json(json_string, default = {}) + return default if json_string.nil? || json_string.empty? + + JSON.parse(json_string) + rescue JSON::ParserError => e + logger.warn "Failed to parse JSON: #{e.message}, using default: #{default}" + default + end + + # Helper to format currency amounts + def format_currency(cents, currency = 'USD') + return '$0.00' unless cents&.positive? + + dollars = cents.to_f / 100 + "$#{'%.2f' % dollars}" + end + + # Helper to validate required parameters + def validate_required_params(params, *required_keys) + missing_keys = required_keys - params.keys.map(&:to_s) + + if missing_keys.any? + raise ArgumentError, "Missing required parameters: #{missing_keys.join(', ')}" + end + end + + # Helper to handle API errors with retry logic + def with_api_retry(max_attempts: 3, &block) + attempts = 0 + + begin + attempts += 1 + yield + rescue BackendApiClient::ApiError => e + if attempts < max_attempts && retryable_error?(e) + logger.warn "API call failed (attempt #{attempts}/#{max_attempts}): #{e.message}, retrying..." + sleep(2 ** attempts) # Exponential backoff + retry + else + logger.error "API call failed after #{attempts} attempts: #{e.message}" + raise + end + end + end + + private + + def retryable_error?(error) + case error.status + when 408, 429, 500, 502, 503, 504 + true + else + false + end + end + + protected + + def log_job_start(args) + Sidekiq.logger.info "#{self.class.name} started with args: #{sanitize_args(args)}" + end + + def log_job_completion(start_time, result) + duration = ((Time.current - start_time) * 1000).round(2) + Sidekiq.logger.info "#{self.class.name} completed in #{duration}ms" + end + + def log_job_error(error, args) + Sidekiq.logger.error "#{self.class.name} failed: #{error.message}" + Sidekiq.logger.error "Args: #{sanitize_args(args)}" + Sidekiq.logger.error error.backtrace.join("\n") + end + + def sanitize_args(args) + # Remove sensitive data from logs + args.map do |arg| + case arg + when Hash + arg.except('password', 'token', 'secret_key', 'credit_card') + else + arg + end + end + end + + def cleanup + # Override in subclasses for resource cleanup + end + + # Delegate to worker service + def delegate_to_worker(job_type, job_data, queue: 'default') + WorkerJobService.enqueue_billing_job(job_type, job_data.merge( + originated_from: self.class.name, + queue: queue + )) + end +end +``` + +#### Billing Job Categories +```ruby +# app/jobs/billing/subscription_renewal_job.rb +class Billing::SubscriptionRenewalJob < BaseJob + sidekiq_options queue: 'high', retry: 5 + + def execute(args) + subscription_id = args['subscription_id'] + retry_attempt = args['retry_attempt'] || 0 + + subscription = Subscription.find(subscription_id) + + # Delegate complex renewal logic to worker service + delegate_to_worker('subscription_renewal', { + subscription_id: subscription_id, + retry_attempt: retry_attempt, + scheduled_at: Time.current.iso8601 + }) + + { subscription_id: subscription_id, delegated_to_worker: true } + rescue ActiveRecord::RecordNotFound + Sidekiq.logger.error "Subscription not found: #{subscription_id}" + { error: "Subscription not found", subscription_id: subscription_id } + end +end + +# app/jobs/billing/dunning_process_job.rb +class Billing::DunningProcessJob < BaseJob + sidekiq_options queue: 'high', retry: 3 + + def execute(args) + subscription_id = args['subscription_id'] + dunning_stage = args['dunning_stage'] || 1 + + subscription = Subscription.find(subscription_id) + + # Check if subscription is still eligible for dunning + unless subscription.past_due? + return { skipped: true, reason: "Subscription no longer past due" } + end + + # Delegate to worker service for dunning process + delegate_to_worker('dunning_process', { + subscription_id: subscription_id, + dunning_stage: dunning_stage, + initiated_at: Time.current.iso8601 + }) + + { subscription_id: subscription_id, dunning_stage: dunning_stage } + end +end + +# app/jobs/billing/payment_retry_job.rb +class Billing::PaymentRetryJob < BaseJob + sidekiq_options queue: 'high', retry: 2 + + def execute(args) + payment_id = args['payment_id'] + retry_attempt = args['retry_attempt'] || 1 + + payment = Payment.find(payment_id) + + # Check retry eligibility + if payment.succeeded? || retry_attempt > 3 + return { skipped: true, reason: "Payment succeeded or max retries exceeded" } + end + + # Delegate to worker service + delegate_to_worker('payment_retry', { + payment_id: payment_id, + retry_attempt: retry_attempt, + initiated_at: Time.current.iso8601 + }) + + { payment_id: payment_id, retry_attempt: retry_attempt } + end +end +``` + +#### API-Only Communication Pattern (CRITICAL) +**MANDATORY**: Workers must use API client for all backend communication, never direct database access. + +```ruby +# app/services/backend_api_client.rb - Discovered platform pattern +class BackendApiClient + include Singleton + + BASE_URL = ENV.fetch('BACKEND_API_URL', 'http://localhost:3000/api/v1') + + class ApiError < StandardError + attr_reader :status, :response_body + + def initialize(message, status = nil, response_body = nil) + super(message) + @status = status + @response_body = response_body + end + end + + def initialize + @token = ENV.fetch('WORKER_TOKEN') + @timeout = 30 + end + + # Standard REST operations + def get(path) + make_request(:get, path) + end + + def post(path, data) + make_request(:post, path, data) + end + + def put(path, data) + make_request(:put, path, data) + end + + def delete(path) + make_request(:delete, path) + end + + # Subscription-specific methods + def renew_subscription(subscription_id) + post("/subscriptions/#{subscription_id}/renew", {}) + end + + def cancel_subscription(subscription_id, reason) + post("/subscriptions/#{subscription_id}/cancel", { reason: reason }) + end + + def process_payment(payment_data) + post('/payments', payment_data) + end + + def send_notification(notification_data) + post('/notifications', notification_data) + end + + private + + def make_request(method, path, data = nil) + url = "#{BASE_URL}#{path}" + + options = { + headers: headers, + timeout: @timeout, + open_timeout: 10 + } + + options[:body] = data.to_json if data && (method == :post || method == :put) + + response = HTTParty.send(method, url, options) + + handle_response(response) + rescue Net::TimeoutError => e + raise ApiError.new("Request timeout: #{e.message}", 408) + rescue StandardError => e + raise ApiError.new("Request failed: #{e.message}") + end + + def handle_response(response) + case response.code + when 200..299 + response.parsed_response + when 400..499 + error_message = response.parsed_response&.dig('error') || 'Client error' + raise ApiError.new(error_message, response.code, response.body) + when 500..599 + error_message = response.parsed_response&.dig('error') || 'Server error' + raise ApiError.new(error_message, response.code, response.body) + else + raise ApiError.new("Unexpected response: #{response.code}", response.code, response.body) + end + end + + def headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{@token}", + 'User-Agent' => 'Powernode-Worker/1.0' + } + end +end +``` + +#### Standard Job Implementation Pattern +```ruby +# Example: Subscription renewal job using API-only pattern +class SubscriptionRenewalJob < BaseJob + sidekiq_options queue: 'billing', retry: 5 + + def execute(subscription_id) + validate_required_params({ 'subscription_id' => subscription_id }, 'subscription_id') + + logger.info "Processing subscription renewal for: #{subscription_id}" + + # Use API client - NO direct database access + with_api_retry(max_attempts: 3) do + result = api_client.renew_subscription(subscription_id) + + if result['success'] + logger.info "Successfully renewed subscription: #{subscription_id}" + + # Send notification via API + api_client.send_notification({ + type: 'subscription_renewed', + subscription_id: subscription_id, + amount: result.dig('data', 'amount') + }) + else + logger.error "Failed to renew subscription: #{result['error']}" + raise StandardError, result['error'] + end + end + end +end +``` + +**CRITICAL RULES** for worker jobs: +1. **NO ActiveRecord models**: Never `require` or use Rails models directly +2. **API-only communication**: All data access through `BackendApiClient` +3. **Inherit from BaseJob**: Never inherit from `ApplicationJob` +4. **Use execute method**: Never override `perform`, always use `execute` +5. **Environment isolation**: Workers run independently of main Rails app + +### 3. Scheduled Job Management (MANDATORY) + +#### Scheduler Jobs +```ruby +# app/jobs/billing/subscription_renewal_scheduler_job.rb +class Billing::SubscriptionRenewalSchedulerJob < BaseJob + sidekiq_options queue: 'high', retry: 1 + + def execute(*args) + # Find subscriptions due for renewal + due_subscriptions = Subscription.joins(:plan) + .renewable + .where('next_billing_date <= ?', Time.current) + .limit(1000) # Process in batches + + scheduled_count = 0 + + due_subscriptions.find_each do |subscription| + # Schedule individual renewal job + Billing::SubscriptionRenewalJob.perform_async({ + 'subscription_id' => subscription.id, + 'scheduled_by' => 'renewal_scheduler' + }) + + scheduled_count += 1 + + # Update next billing date to prevent duplicate processing + subscription.update_column(:last_renewal_check, Time.current) + end + + Sidekiq.logger.info "Scheduled #{scheduled_count} subscription renewals" + + { scheduled_renewals: scheduled_count, processed_at: Time.current.iso8601 } + end +end + +# app/jobs/billing/dunning_process_scheduler_job.rb +class Billing::DunningProcessSchedulerJob < BaseJob + sidekiq_options queue: 'default', retry: 1 + + def execute(*args) + # Find subscriptions in dunning process + past_due_subscriptions = Subscription.past_due + .where('last_dunning_date IS NULL OR last_dunning_date < ?', 1.day.ago) + .limit(500) + + scheduled_count = 0 + + past_due_subscriptions.find_each do |subscription| + # Determine next dunning stage + next_stage = calculate_dunning_stage(subscription) + + if next_stage + Billing::DunningProcessJob.perform_async({ + 'subscription_id' => subscription.id, + 'dunning_stage' => next_stage, + 'scheduled_by' => 'dunning_scheduler' + }) + + scheduled_count += 1 + end + end + + { scheduled_dunning_processes: scheduled_count } + end + + private + + def calculate_dunning_stage(subscription) + days_past_due = (Time.current - subscription.became_past_due_at).to_i / 1.day + current_stage = subscription.dunning_stage || 0 + + case days_past_due + when 1..2 + current_stage < 1 ? 1 : nil + when 3..6 + current_stage < 2 ? 2 : nil + when 7..13 + current_stage < 3 ? 3 : nil + when 14..20 + current_stage < 4 ? 4 : nil + when 21..29 + current_stage < 5 ? 5 : nil + when 30..Float::INFINITY + current_stage < 6 ? 6 : nil + else + nil + end + end +end +``` + +#### Batch Processing Jobs +```ruby +# app/jobs/billing/billing_cleanup_job.rb +class Billing::BillingCleanupJob < BaseJob + sidekiq_options queue: 'low', retry: 1 + + def execute(*args) + cleanup_results = {} + + # Cleanup old payment intents + cleanup_results[:payment_intents] = cleanup_old_payment_intents + + # Archive old invoices + cleanup_results[:invoices] = archive_old_invoices + + # Remove expired blacklisted tokens + cleanup_results[:blacklisted_tokens] = cleanup_blacklisted_tokens + + # Cleanup audit logs older than retention period + cleanup_results[:audit_logs] = cleanup_old_audit_logs + + Sidekiq.logger.info "Billing cleanup completed: #{cleanup_results}" + cleanup_results + end + + private + + def cleanup_old_payment_intents + # Remove payment intents older than 90 days + count = PaymentIntent.where('created_at < ?', 90.days.ago).delete_all + Sidekiq.logger.info "Cleaned up #{count} old payment intents" + count + end + + def archive_old_invoices + # Archive invoices older than 2 years + old_invoices = Invoice.where('created_at < ?', 2.years.ago).limit(1000) + + archived_count = 0 + old_invoices.find_each do |invoice| + # Delegate archival to worker service + delegate_to_worker('archive_invoice', { + invoice_id: invoice.id, + archive_reason: 'age_retention' + }, queue: 'batch') + + archived_count += 1 + end + + archived_count + end + + def cleanup_blacklisted_tokens + count = BlacklistedToken.where('expires_at < ?', Time.current).delete_all + count + end + + def cleanup_old_audit_logs + # Keep audit logs for 7 years for compliance + retention_date = 7.years.ago + count = AuditLog.where('created_at < ?', retention_date).delete_all + count + end +end +``` + +### 4. Job Monitoring and Metrics (MANDATORY) + +#### Job Metrics Middleware +```ruby +# app/middleware/job_metrics_middleware.rb +class JobMetricsMiddleware + def call(job, queue) + job_class = job['class'] + start_time = Time.current + + # Increment job started counter + increment_counter("sidekiq.jobs.started", tags: { job_class: job_class, queue: queue }) + + begin + yield + + # Record successful completion + duration = Time.current - start_time + record_histogram("sidekiq.jobs.duration", duration, tags: { job_class: job_class, queue: queue }) + increment_counter("sidekiq.jobs.completed", tags: { job_class: job_class, queue: queue }) + + rescue StandardError => e + # Record job failure + increment_counter("sidekiq.jobs.failed", tags: { job_class: job_class, queue: queue, error: e.class.name }) + raise e + end + end + + private + + def increment_counter(metric_name, tags: {}) + # Send metrics to your monitoring system (DataDog, New Relic, etc.) + Rails.logger.info "METRIC: #{metric_name} #{tags.inspect}" + + # Example: DataDog integration + # Datadog::Statsd.increment(metric_name, tags: tags.map { |k, v| "#{k}:#{v}" }) + end + + def record_histogram(metric_name, value, tags: {}) + Rails.logger.info "HISTOGRAM: #{metric_name} #{value} #{tags.inspect}" + + # Example: DataDog integration + # Datadog::Statsd.histogram(metric_name, value, tags: tags.map { |k, v| "#{k}:#{v}" }) + end +end + +# app/middleware/job_audit_middleware.rb +class JobAuditMiddleware + def call(job, queue) + job_execution = JobExecution.create!( + job_class: job['class'], + job_id: job['jid'], + queue: queue, + args: sanitize_args(job['args']), + started_at: Time.current, + status: 'running' + ) + + begin + result = yield + + job_execution.update!( + completed_at: Time.current, + status: 'completed', + result: result.is_a?(Hash) ? result : { success: true } + ) + + result + rescue StandardError => e + job_execution.update!( + completed_at: Time.current, + status: 'failed', + error_message: e.message, + error_backtrace: e.backtrace&.first(10) + ) + + raise e + end + end + + private + + def sanitize_args(args) + # Remove sensitive information from job arguments + return args unless args.is_a?(Array) + + args.map do |arg| + if arg.is_a?(Hash) + arg.except('password', 'token', 'secret_key', 'api_key', 'credit_card_number') + else + arg + end + end + end +end +``` + +#### Job Performance Monitoring +```ruby +# app/services/job_performance_monitor.rb +class JobPerformanceMonitor + def self.report + { + queue_stats: queue_statistics, + job_stats: job_statistics, + worker_stats: worker_statistics, + alerts: performance_alerts + } + end + + def self.queue_statistics + stats = Sidekiq::Stats.new + + { + processed: stats.processed, + failed: stats.failed, + busy: stats.workers_size, + queues: stats.queues, + retries: stats.retry_size, + dead: stats.dead_size, + scheduled: stats.scheduled_size + } + end + + def self.job_statistics + # Get job statistics from the last 24 hours + recent_executions = JobExecution.where('started_at > ?', 24.hours.ago) + + { + total_jobs: recent_executions.count, + successful_jobs: recent_executions.where(status: 'completed').count, + failed_jobs: recent_executions.where(status: 'failed').count, + average_duration: recent_executions.where.not(completed_at: nil) + .average('EXTRACT(EPOCH FROM (completed_at - started_at))'), + slowest_jobs: recent_executions.where.not(completed_at: nil) + .order('(completed_at - started_at) DESC') + .limit(10) + .pluck(:job_class, 'EXTRACT(EPOCH FROM (completed_at - started_at))') + } + end + + def self.worker_statistics + # Worker service integration statistics + { + worker_jobs_delegated: WorkerJobDelegation.where('created_at > ?', 24.hours.ago).count, + worker_success_rate: calculate_worker_success_rate, + average_worker_response_time: calculate_average_worker_response_time + } + end + + def self.performance_alerts + alerts = [] + + # Check for high failure rates + failure_rate = failed_job_rate_last_hour + alerts << "High job failure rate: #{failure_rate.round(2)}%" if failure_rate > 10 + + # Check for queue buildup + stats = Sidekiq::Stats.new + stats.queues.each do |queue_name, queue_size| + alerts << "Queue #{queue_name} has #{queue_size} jobs" if queue_size > 1000 + end + + # Check for slow jobs + slow_jobs = JobExecution.where('started_at > ? AND completed_at IS NOT NULL', 1.hour.ago) + .where('EXTRACT(EPOCH FROM (completed_at - started_at)) > ?', 300) # 5 minutes + + if slow_jobs.count > 10 + alerts << "#{slow_jobs.count} slow jobs (>5 minutes) in the last hour" + end + + alerts + end + + private + + def self.failed_job_rate_last_hour + total_jobs = JobExecution.where('started_at > ?', 1.hour.ago).count + failed_jobs = JobExecution.where('started_at > ?', 1.hour.ago).where(status: 'failed').count + + return 0 if total_jobs == 0 + (failed_jobs.to_f / total_jobs * 100) + end + + def self.calculate_worker_success_rate + delegations = WorkerJobDelegation.where('created_at > ?', 24.hours.ago) + return 100 if delegations.count == 0 + + successful = delegations.where(status: 'completed').count + (successful.to_f / delegations.count * 100).round(2) + end + + def self.calculate_average_worker_response_time + completed_delegations = WorkerJobDelegation.where('created_at > ?', 24.hours.ago) + .where.not(completed_at: nil) + + return 0 if completed_delegations.count == 0 + + completed_delegations.average('EXTRACT(EPOCH FROM (completed_at - created_at))') || 0 + end +end +``` + +### 5. Worker Service Integration (MANDATORY) + +#### Worker Job Delegation Service +```ruby +# app/services/worker_job_service.rb +class WorkerJobService + class WorkerServiceError < StandardError; end + + API_TIMEOUT = 30.seconds + MAX_RETRIES = 3 + + def self.enqueue_billing_job(job_type, job_data, queue: 'default') + new.enqueue_job('billing', job_type, job_data, queue) + end + + def self.enqueue_notification_job(job_type, job_data, queue: 'default') + new.enqueue_job('notifications', job_type, job_data, queue) + end + + def self.enqueue_analytics_job(job_type, job_data, queue: 'default') + new.enqueue_job('analytics', job_type, job_data, queue) + end + + def self.cancel_billing_job(job_type, criteria = {}) + new.cancel_job('billing', job_type, criteria) + end + + def initialize + @worker_api_client = BackendApiClient.new( + base_url: Rails.application.config.worker_url, + token: Rails.application.config.worker_token, + timeout: API_TIMEOUT + ) + end + + def enqueue_job(category, job_type, job_data, queue = 'default') + delegation_record = create_delegation_record(category, job_type, job_data, queue) + + begin + response = @worker_api_client.post('/jobs', { + job_category: category, + job_type: job_type, + job_data: job_data.merge(delegation_id: delegation_record.id), + queue: queue, + priority: calculate_priority(job_type), + retry_attempts: MAX_RETRIES, + enqueued_by: 'backend_api', + enqueued_at: Time.current.iso8601 + }) + + if response.success? + delegation_record.update!( + worker_job_id: response.data['job_id'], + status: 'enqueued', + enqueued_at: Time.current + ) + + Rails.logger.info "Successfully enqueued #{job_type} job: #{response.data['job_id']}" + response.data + else + delegation_record.update!(status: 'failed', error_message: response.error) + raise WorkerServiceError, "Failed to enqueue job: #{response.error}" + end + + rescue StandardError => e + delegation_record.update!(status: 'failed', error_message: e.message) + Rails.logger.error "Worker service error for #{job_type}: #{e.message}" + raise WorkerServiceError, e.message + end + end + + def cancel_job(category, job_type, criteria) + begin + response = @worker_api_client.delete('/jobs/cancel', { + job_category: category, + job_type: job_type, + criteria: criteria + }) + + if response.success? + # Update local delegation records + WorkerJobDelegation.where( + job_category: category, + job_type: job_type, + status: ['pending', 'enqueued'] + ).update_all( + status: 'cancelled', + cancelled_at: Time.current + ) + + Rails.logger.info "Successfully cancelled #{job_type} jobs matching #{criteria}" + response.data + else + raise WorkerServiceError, "Failed to cancel jobs: #{response.error}" + end + + rescue StandardError => e + Rails.logger.error "Worker job cancellation error: #{e.message}" + raise WorkerServiceError, e.message + end + end + + def get_job_status(worker_job_id) + begin + response = @worker_api_client.get("/jobs/#{worker_job_id}/status") + + if response.success? + # Update local delegation record + delegation = WorkerJobDelegation.find_by(worker_job_id: worker_job_id) + if delegation + delegation.update!( + status: response.data['status'], + completed_at: response.data['completed_at'] ? Time.parse(response.data['completed_at']) : nil, + result: response.data['result'] + ) + end + + response.data + else + raise WorkerServiceError, "Failed to get job status: #{response.error}" + end + + rescue StandardError => e + Rails.logger.error "Worker job status check error: #{e.message}" + raise WorkerServiceError, e.message + end + end + + private + + def create_delegation_record(category, job_type, job_data, queue) + WorkerJobDelegation.create!( + job_category: category, + job_type: job_type, + job_data: job_data, + queue: queue, + status: 'pending', + created_at: Time.current + ) + end + + def calculate_priority(job_type) + case job_type + when /renewal/, /payment_failed/, /dunning/ + 'high' + when /notification/, /email/ + 'default' + when /analytics/, /cleanup/, /archive/ + 'low' + else + 'default' + end + end +end + +# app/models/worker_job_delegation.rb +class WorkerJobDelegation < ApplicationRecord + validates :job_category, presence: true + validates :job_type, presence: true + validates :status, inclusion: { in: %w[pending enqueued running completed failed cancelled] } + + scope :pending, -> { where(status: 'pending') } + scope :running, -> { where(status: 'running') } + scope :completed, -> { where(status: 'completed') } + scope :failed, -> { where(status: 'failed') } + + def duration + return nil unless completed_at && enqueued_at + completed_at - enqueued_at + end + + def success? + status == 'completed' + end +end +``` + +### 6. Error Handling and Retry Logic (MANDATORY) + +#### Job Retry Strategies +```ruby +# app/jobs/concerns/retry_strategies.rb +module RetryStrategies + extend ActiveSupport::Concern + + included do + # Custom retry logic based on error type + sidekiq_retry_in do |count, exception| + case exception + when PaymentGatewayError + # Exponential backoff for payment errors + [1.minute, 5.minutes, 15.minutes, 1.hour, 4.hours][count] || 24.hours + when WorkerServiceError + # Quick retry for worker service connectivity issues + [30.seconds, 2.minutes, 10.minutes][count] || :kill + when ActiveRecord::RecordNotFound + # Don't retry for missing records + :kill + when ActiveRecord::ConnectionTimeoutError + # Short retry for DB connection issues + [10.seconds, 30.seconds, 1.minute][count] || 5.minutes + else + # Default exponential backoff + (count ** 2) + 15.seconds + end + end + + # Death handler for jobs that can't be retried + sidekiq_retries_exhausted do |job, exception| + handle_job_failure(job, exception) + end + end + + class_methods do + def handle_job_failure(job, exception) + job_class = job['class'] + job_args = job['args'] + + # Log the failure + Sidekiq.logger.error "Job #{job_class} permanently failed: #{exception.message}" + + # Create failure record + JobFailure.create!( + job_class: job_class, + job_args: job_args, + error_class: exception.class.name, + error_message: exception.message, + failed_at: Time.current, + retry_count: job['retry_count'] + ) + + # Send alert for critical jobs + if critical_job?(job_class) + JobFailureNotificationService.call( + job_class: job_class, + job_args: job_args, + error: exception.message, + critical: true + ) + end + + # Handle specific failure types + case job_class + when 'Billing::SubscriptionRenewalJob' + handle_renewal_failure(job_args.first) + when 'Billing::PaymentRetryJob' + handle_payment_failure(job_args.first) + end + end + + private + + def critical_job?(job_class) + %w[ + Billing::SubscriptionRenewalJob + Billing::PaymentRetryJob + Billing::DunningProcessJob + SystemHealthCheckJob + ].include?(job_class) + end + + def handle_renewal_failure(job_args) + subscription_id = job_args['subscription_id'] + + # Mark subscription as requiring manual intervention + subscription = Subscription.find_by(id: subscription_id) + if subscription + subscription.update!( + status: 'renewal_failed', + requires_manual_intervention: true, + last_renewal_failure_at: Time.current + ) + + # Notify admin team + AdminNotificationService.call( + type: 'subscription_renewal_failed', + subscription_id: subscription_id, + priority: 'high' + ) + end + end + + def handle_payment_failure(job_args) + payment_id = job_args['payment_id'] + + # Mark payment as permanently failed + payment = Payment.find_by(id: payment_id) + if payment + payment.update!( + status: 'permanently_failed', + permanent_failure_at: Time.current + ) + + # Start manual dunning process + WorkerJobService.enqueue_billing_job('manual_dunning_required', { + payment_id: payment_id, + subscription_id: payment.subscription_id + }) + end + end + end +end +``` + +#### Error Notification Middleware +```ruby +# app/middleware/error_notification_middleware.rb +class ErrorNotificationMiddleware + def call(job, queue) + yield + rescue StandardError => e + # Determine if this error needs immediate notification + if should_notify_immediately?(e, job) + send_immediate_error_notification(e, job, queue) + end + + # Re-raise to let Sidekiq handle retry logic + raise e + end + + private + + def should_notify_immediately?(error, job) + # Notify immediately for critical errors or critical jobs + critical_errors = [ + PaymentGatewayError, + WorkerServiceError, + SecurityError + ] + + critical_jobs = [ + 'Billing::SubscriptionRenewalJob', + 'Billing::PaymentRetryJob', + 'SystemHealthCheckJob' + ] + + critical_errors.any? { |err_class| error.is_a?(err_class) } || + critical_jobs.include?(job['class']) + end + + def send_immediate_error_notification(error, job, queue) + JobFailureNotificationService.perform_async({ + 'job_class' => job['class'], + 'queue' => queue, + 'error_class' => error.class.name, + 'error_message' => error.message, + 'job_args' => sanitize_job_args(job['args']), + 'occurred_at' => Time.current.iso8601, + 'immediate_notification' => true + }) + end + + def sanitize_job_args(args) + # Remove sensitive information + args.deep_dup.tap do |sanitized_args| + sanitized_args.each do |arg| + if arg.is_a?(Hash) + arg.delete('password') + arg.delete('token') + arg.delete('api_key') + arg.delete('secret') + end + end + end + rescue + [''] + end +end +``` + +## Development Commands + +### Background Job Management +```bash +# Start Sidekiq with configuration +bundle exec sidekiq -C config/sidekiq.yml -e development + +# Start Sidekiq web interface +bundle exec sidekiq -C config/sidekiq.yml -e development & +open http://localhost:4567/sidekiq + +# Monitor job queues +bundle exec sidekiq-cli stats +bundle exec sidekiq-cli busy + +# Test job execution +rails console +> Billing::SubscriptionRenewalJob.perform_async({'subscription_id' => '123'}) +> JobPerformanceMonitor.report + +# Clear queues (development only) +Sidekiq::Queue.new('critical').clear +Sidekiq::Queue.new('high').clear +Sidekiq::RetrySet.new.clear +Sidekiq::DeadSet.new.clear +``` + +### Job Testing and Debugging +```bash +# Run job-related tests +bundle exec rspec spec/jobs/ +bundle exec rspec spec/services/worker_job_service_spec.rb + +# Test worker service integration +curl -X POST http://localhost:4567/jobs \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + -d '{"job_type": "test", "job_data": {}}' + +# Monitor job performance +rails runner "puts JobPerformanceMonitor.report.to_yaml" +``` + +## Integration Points + +### Background Job Engineer Coordinates With: +- **Billing Engine Developer**: Automated billing processes, subscription lifecycles +- **Payment Integration Specialist**: Payment retry mechanisms, webhook processing +- **Rails Architect**: Job middleware configuration, error handling +- **Notification Engineer**: Email delivery, notification queuing +- **DevOps Engineer**: Queue monitoring, performance optimization + +## Quick Reference + +### Job Categories and Queues +- **critical**: System health, security alerts +- **high**: Billing, payments, renewals, dunning +- **default**: Notifications, regular processing +- **low**: Cleanup, analytics, archival +- **batch**: Large batch operations + +### Standard Job Template (Platform Pattern) +```ruby +class SampleJob < BaseJob + sidekiq_options queue: 'default', retry: 3 + + def execute(resource_id, options = {}) + # 1. Validate required parameters + validate_required_params({ 'resource_id' => resource_id }, 'resource_id') + + logger.info "Processing resource: #{resource_id}" + + # 2. Use API client for all data access + with_api_retry(max_attempts: 3) do + result = api_client.post("/resources/#{resource_id}/process", options) + + if result['success'] + logger.info "Successfully processed resource: #{resource_id}" + + # 3. Send notifications via API + api_client.send_notification({ + type: 'resource_processed', + resource_id: resource_id, + processed_at: Time.current.iso8601 + }) + + result['data'] + else + raise StandardError, result['error'] + end + end + end +end +``` + +### Pattern Validation Commands +```bash +# Ensure BaseJob inheritance (should be > 0) +grep -r "< BaseJob" worker/app/jobs/ | wc -l + +# Find forbidden ApplicationJob inheritance (should be empty) +grep -r "< ApplicationJob" worker/app/jobs/ + +# Find forbidden ActiveRecord usage (should be empty) +grep -r "ActiveRecord" worker/app/ | grep -v comments + +# Find jobs using execute method (should match BaseJob count) +grep -r "def execute" worker/app/jobs/ | wc -l + +# Find forbidden perform method overrides (should be empty) +grep -r "def perform" worker/app/jobs/ | grep -v BaseJob +``` + +### Worker Service Integration +```ruby +# Enqueue billing job +WorkerJobService.enqueue_billing_job('subscription_renewal', { + subscription_id: subscription.id, + scheduled_for: Time.current.iso8601 +}) + +# Cancel scheduled jobs +WorkerJobService.cancel_billing_job('subscription_renewal', { + subscription_id: subscription.id +}) + +# Check job status +WorkerJobService.new.get_job_status(worker_job_id) +``` diff --git a/docs/backend/BILLING_ENGINE_DEVELOPER_SPECIALIST.md b/docs/backend/BILLING_ENGINE_DEVELOPER_SPECIALIST.md new file mode 100644 index 000000000..1e5c2bf69 --- /dev/null +++ b/docs/backend/BILLING_ENGINE_DEVELOPER_SPECIALIST.md @@ -0,0 +1,1395 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# Billing Engine Developer Specialist Guide + +## Role & Responsibilities + +The Billing Engine Developer specializes in subscription lifecycle management, automated billing, proration calculations, and dunning systems for Powernode's subscription platform. + +### Core Responsibilities +- Implementing subscription lifecycle management +- Building automated renewal systems +- Handling proration calculations +- Developing dunning and recovery systems +- Creating invoicing and PDF generation + +### Key Focus Areas +- Complex billing logic and calculations +- Sidekiq background jobs for automated processes +- Automated retry mechanisms and failure handling +- Revenue recognition and accounting integration +- Subscription plan changes and upgrades + +## Billing Engine Architecture Standards + +### 1. Subscription Lifecycle Management (MANDATORY) + +#### Subscription State Machine +```ruby +# app/models/subscription.rb +class Subscription < ApplicationRecord + include AASM + + belongs_to :account + belongs_to :plan + has_many :payments, dependent: :destroy + has_many :invoices, dependent: :destroy + has_many :subscription_changes, dependent: :destroy + + monetize :amount_cents + + aasm column: :status do + state :pending, initial: true + state :active + state :past_due + state :cancelled + state :suspended + state :expired + + event :activate do + transitions from: [:pending, :past_due, :suspended], to: :active + after do + update_billing_cycle + schedule_next_billing + end + end + + event :mark_past_due do + transitions from: :active, to: :past_due + after do + start_dunning_process + end + end + + event :cancel do + transitions from: [:active, :past_due, :suspended], to: :cancelled + after do + process_cancellation + cancel_scheduled_billing + end + end + + event :suspend do + transitions from: [:active, :past_due], to: :suspended + after do + suspend_services + cancel_scheduled_billing + end + end + + event :expire do + transitions from: [:active, :past_due, :suspended], to: :expired + after do + process_expiration + end + end + end + + scope :active, -> { where(status: 'active') } + scope :renewable, -> { where(status: ['active', 'past_due']) } + scope :due_for_renewal, -> { where('next_billing_date <= ?', Time.current) } + + def days_until_renewal + return 0 unless next_billing_date + ((next_billing_date - Time.current) / 1.day).ceil + end + + def current_billing_cycle + { + start: current_period_start, + end: current_period_end, + days_total: (current_period_end - current_period_start).to_i / 1.day, + days_remaining: [(current_period_end - Time.current).to_i / 1.day, 0].max + } + end + + def prorated_amount_for_upgrade(new_plan) + return new_plan.price_cents if current_period_start == Time.current.beginning_of_day + + ProrationCalculatorService.call( + subscription: self, + new_plan: new_plan, + change_date: Time.current + ).result + end + + private + + def update_billing_cycle + self.current_period_start = Time.current + self.current_period_end = calculate_period_end + self.next_billing_date = current_period_end + save! + end + + def calculate_period_end + case plan.billing_interval + when 'month' + current_period_start + 1.month + when 'year' + current_period_start + 1.year + when 'week' + current_period_start + 1.week + else + raise "Invalid billing interval: #{plan.billing_interval}" + end + end + + def schedule_next_billing + WorkerJobService.enqueue_billing_job('subscription_renewal', { + subscription_id: id, + scheduled_for: next_billing_date.iso8601 + }) + end + + def cancel_scheduled_billing + # Cancel any pending renewal jobs + WorkerJobService.cancel_billing_job('subscription_renewal', subscription_id: id) + end + + def start_dunning_process + WorkerJobService.enqueue_billing_job('dunning_process_start', { + subscription_id: id, + past_due_date: Time.current.iso8601 + }) + end + + def process_cancellation + self.cancelled_at = Time.current + self.cancellation_reason = 'user_requested' + save! + + # Process any final billing + WorkerJobService.enqueue_billing_job('final_billing', { subscription_id: id }) + end + + def suspend_services + # Disable account access, API keys, etc. + account.update!(status: 'suspended', suspended_at: Time.current) + end + + def process_expiration + self.expired_at = Time.current + save! + + # Cleanup and archival + WorkerJobService.enqueue_billing_job('subscription_cleanup', { subscription_id: id }) + end +end +``` + +#### Subscription Service Layer +```ruby +# app/services/subscription_lifecycle_service.rb +class SubscriptionLifecycleService < BaseService + attribute :subscription, Subscription + attribute :action, String + attribute :metadata, Hash, default: {} + + VALID_ACTIONS = %w[activate cancel suspend reactivate upgrade downgrade].freeze + + validates :subscription, :action, presence: true + validates :action, inclusion: { in: VALID_ACTIONS } + + def call + return failure("Invalid parameters", errors.full_messages) unless valid? + + begin + case action + when 'activate' + activate_subscription + when 'cancel' + cancel_subscription + when 'suspend' + suspend_subscription + when 'reactivate' + reactivate_subscription + when 'upgrade', 'downgrade' + change_subscription_plan + end + rescue StandardError => e + Rails.logger.error "Subscription lifecycle action failed: #{e.message}" + failure("Lifecycle action failed", { error: e.message }) + end + end + + private + + def activate_subscription + if subscription.may_activate? + subscription.activate! + + # Create activation invoice if needed + create_activation_invoice if should_invoice_activation? + + # Send welcome notification + WorkerJobService.enqueue_billing_job('subscription_activated_notification', { + subscription_id: subscription.id + }) + + success({ subscription: subscription_data }) + else + failure("Cannot activate subscription in current state: #{subscription.status}") + end + end + + def cancel_subscription + cancellation_type = metadata[:cancellation_type] || 'immediate' + + case cancellation_type + when 'immediate' + immediate_cancellation + when 'end_of_period' + end_of_period_cancellation + else + failure("Invalid cancellation type: #{cancellation_type}") + end + end + + def immediate_cancellation + if subscription.may_cancel? + # Calculate any refunds due + refund_amount = calculate_refund_amount + + subscription.cancel! + + # Process refund if applicable + if refund_amount > 0 + WorkerJobService.enqueue_billing_job('process_refund', { + subscription_id: subscription.id, + refund_amount_cents: refund_amount + }) + end + + success({ + subscription: subscription_data, + refund_amount_cents: refund_amount + }) + else + failure("Cannot cancel subscription in current state: #{subscription.status}") + end + end + + def end_of_period_cancellation + subscription.update!( + cancellation_scheduled: true, + cancellation_date: subscription.current_period_end, + cancellation_reason: metadata[:reason] || 'user_requested' + ) + + # Schedule cancellation + WorkerJobService.enqueue_billing_job('scheduled_cancellation', { + subscription_id: subscription.id, + scheduled_for: subscription.current_period_end.iso8601 + }) + + success({ + subscription: subscription_data, + cancellation_scheduled_for: subscription.current_period_end.iso8601 + }) + end + + def change_subscription_plan + new_plan_id = metadata[:new_plan_id] + new_plan = Plan.find(new_plan_id) + + change_service = SubscriptionPlanChangeService.call( + subscription: subscription, + new_plan: new_plan, + change_type: action, + effective_date: metadata[:effective_date] || Time.current + ) + + if change_service.success? + success(change_service.data) + else + failure(change_service.error, change_service.details) + end + end + + def should_invoice_activation? + !subscription.trial? && subscription.plan.price_cents > 0 + end + + def create_activation_invoice + InvoiceGenerationService.call( + subscription: subscription, + invoice_type: 'activation', + due_date: Time.current + ) + end + + def calculate_refund_amount + return 0 unless subscription.active? + + days_remaining = subscription.current_billing_cycle[:days_remaining] + total_days = subscription.current_billing_cycle[:days_total] + + return 0 if days_remaining <= 0 || total_days <= 0 + + prorated_refund = (subscription.amount_cents * days_remaining / total_days).round + [prorated_refund, 0].max + end + + def subscription_data + { + id: subscription.id, + status: subscription.status, + current_period: { + start: subscription.current_period_start&.iso8601, + end: subscription.current_period_end&.iso8601 + }, + next_billing_date: subscription.next_billing_date&.iso8601, + updated_at: subscription.updated_at.iso8601 + } + end +end +``` + +### 2. Automated Renewal System (MANDATORY) + +#### Renewal Processing Service +```ruby +# app/services/subscription_renewal_service.rb +class SubscriptionRenewalService < BaseService + attribute :subscription, Subscription + attribute :retry_attempt, Integer, default: 0 + + MAX_RETRY_ATTEMPTS = 3 + + def call + return failure("Subscription not renewable") unless subscription.renewable? + return failure("Max retry attempts exceeded") if retry_attempt > MAX_RETRY_ATTEMPTS + + begin + ActiveRecord::Base.transaction do + process_renewal + end + rescue PaymentProcessingError => e + handle_payment_failure(e) + rescue StandardError => e + Rails.logger.error "Subscription renewal failed: #{e.message}" + failure("Renewal processing failed", { error: e.message }) + end + end + + private + + def process_renewal + # Generate invoice for upcoming period + invoice = create_renewal_invoice + + # Process payment + payment_result = process_renewal_payment(invoice) + + if payment_result.success? + # Update subscription for next period + advance_billing_period + + # Schedule next renewal + schedule_next_renewal + + # Send success notification + send_renewal_success_notification + + success({ + subscription: subscription_data, + invoice: invoice_data(invoice), + payment: payment_result.data + }) + else + handle_payment_failure(payment_result) + end + end + + def create_renewal_invoice + invoice_service = InvoiceGenerationService.call( + subscription: subscription, + invoice_type: 'renewal', + billing_period_start: subscription.current_period_end, + billing_period_end: calculate_next_period_end, + due_date: subscription.current_period_end + ) + + unless invoice_service.success? + raise StandardError, "Failed to create renewal invoice: #{invoice_service.error}" + end + + invoice_service.data[:invoice] + end + + def process_renewal_payment(invoice) + payment_method = subscription.account.default_payment_method + + unless payment_method + raise PaymentProcessingError, "No payment method available" + end + + PaymentProcessingService.call( + invoice: invoice, + payment_method: payment_method, + description: "Subscription renewal for #{subscription.plan.name}" + ) + end + + def advance_billing_period + subscription.update!( + current_period_start: subscription.current_period_end, + current_period_end: calculate_next_period_end, + next_billing_date: calculate_next_period_end, + renewed_at: Time.current, + renewal_count: subscription.renewal_count + 1 + ) + end + + def calculate_next_period_end + case subscription.plan.billing_interval + when 'month' + subscription.current_period_end + 1.month + when 'year' + subscription.current_period_end + 1.year + when 'week' + subscription.current_period_end + 1.week + end + end + + def schedule_next_renewal + WorkerJobService.enqueue_billing_job('subscription_renewal', { + subscription_id: subscription.id, + scheduled_for: subscription.next_billing_date.iso8601 + }) + end + + def handle_payment_failure(error) + if retry_attempt < MAX_RETRY_ATTEMPTS + schedule_retry + subscription.mark_past_due! if subscription.may_mark_past_due? + + failure("Payment failed, retry scheduled", { + error: error.message, + retry_attempt: retry_attempt + 1, + next_retry: calculate_retry_time + }) + else + # Max retries exceeded - start dunning process + subscription.mark_past_due! if subscription.may_mark_past_due? + + WorkerJobService.enqueue_billing_job('dunning_process_start', { + subscription_id: subscription.id, + final_payment_failure: true + }) + + failure("Payment permanently failed", { + error: error.message, + dunning_process_started: true + }) + end + end + + def schedule_retry + WorkerJobService.enqueue_billing_job('subscription_renewal', { + subscription_id: subscription.id, + retry_attempt: retry_attempt + 1, + scheduled_for: calculate_retry_time.iso8601 + }) + end + + def calculate_retry_time + # Exponential backoff: 1 day, 3 days, 7 days + retry_delays = [1.day, 3.days, 7.days] + retry_delays[retry_attempt] || 7.days + + Time.current + retry_delays[retry_attempt] + end + + def send_renewal_success_notification + WorkerJobService.enqueue_billing_job('subscription_renewed_notification', { + subscription_id: subscription.id, + renewal_date: Time.current.iso8601 + }) + end + + class PaymentProcessingError < StandardError; end +end +``` + +### 3. Proration Calculation System (MANDATORY) + +#### Proration Calculator Service +```ruby +# app/services/proration_calculator_service.rb +class ProrationCalculatorService < BaseService + attribute :subscription, Subscription + attribute :new_plan, Plan + attribute :change_date, DateTime, default: -> { Time.current } + attribute :proration_type, String, default: 'immediate' + + PRORATION_TYPES = %w[immediate end_of_period].freeze + + validates :subscription, :new_plan, :change_date, presence: true + validates :proration_type, inclusion: { in: PRORATION_TYPES } + + def call + return failure("Invalid parameters", errors.full_messages) unless valid? + + begin + calculate_proration + rescue StandardError => e + Rails.logger.error "Proration calculation failed: #{e.message}" + failure("Calculation failed", { error: e.message }) + end + end + + private + + def calculate_proration + case proration_type + when 'immediate' + calculate_immediate_proration + when 'end_of_period' + calculate_end_of_period_change + end + end + + def calculate_immediate_proration + current_plan = subscription.plan + + # Calculate unused time credit from current plan + unused_credit = calculate_unused_credit(current_plan) + + # Calculate prorated charge for new plan + prorated_charge = calculate_prorated_charge(new_plan) + + # Net amount due + net_amount = prorated_charge - unused_credit + + success({ + proration_details: { + current_plan: plan_summary(current_plan), + new_plan: plan_summary(new_plan), + change_date: change_date.iso8601, + billing_period: { + start: subscription.current_period_start.iso8601, + end: subscription.current_period_end.iso8601, + days_total: total_days_in_period, + days_used: days_used_in_period, + days_remaining: days_remaining_in_period + }, + unused_credit_cents: unused_credit, + prorated_charge_cents: prorated_charge, + net_amount_cents: net_amount, + immediate_charge: net_amount > 0, + credit_applied: net_amount < 0 ? net_amount.abs : 0 + } + }) + end + + def calculate_end_of_period_change + # No proration - change happens at end of current period + success({ + proration_details: { + current_plan: plan_summary(subscription.plan), + new_plan: plan_summary(new_plan), + change_date: subscription.current_period_end.iso8601, + billing_period: { + start: subscription.current_period_start.iso8601, + end: subscription.current_period_end.iso8601, + days_remaining: days_remaining_in_period + }, + unused_credit_cents: 0, + prorated_charge_cents: 0, + net_amount_cents: 0, + scheduled_change: true, + effective_date: subscription.current_period_end.iso8601, + next_billing_amount_cents: new_plan.price_cents + } + }) + end + + def calculate_unused_credit(plan) + return 0 if days_remaining_in_period <= 0 + + daily_rate = plan.price_cents.to_f / total_days_in_period + (daily_rate * days_remaining_in_period).round + end + + def calculate_prorated_charge(plan) + return 0 if days_remaining_in_period <= 0 + + daily_rate = plan.price_cents.to_f / total_days_in_period + (daily_rate * days_remaining_in_period).round + end + + def total_days_in_period + @total_days ||= (subscription.current_period_end - subscription.current_period_start).to_i / 1.day + end + + def days_used_in_period + @days_used ||= [((change_date - subscription.current_period_start).to_i / 1.day), 0].max + end + + def days_remaining_in_period + @days_remaining ||= [total_days_in_period - days_used_in_period, 0].max + end + + def plan_summary(plan) + { + id: plan.id, + name: plan.name, + price_cents: plan.price_cents, + billing_interval: plan.billing_interval + } + end +end +``` + +#### Plan Change Service +```ruby +# app/services/subscription_plan_change_service.rb +class SubscriptionPlanChangeService < BaseService + attribute :subscription, Subscription + attribute :new_plan, Plan + attribute :change_type, String # 'upgrade', 'downgrade' + attribute :effective_date, DateTime, default: -> { Time.current } + attribute :proration_type, String, default: 'immediate' + + validates :subscription, :new_plan, :change_type, presence: true + validates :change_type, inclusion: { in: %w[upgrade downgrade] } + + def call + return failure("Invalid parameters", errors.full_messages) unless valid? + return failure("Cannot change to same plan") if subscription.plan_id == new_plan.id + + begin + ActiveRecord::Base.transaction do + process_plan_change + end + rescue StandardError => e + Rails.logger.error "Plan change failed: #{e.message}" + failure("Plan change failed", { error: e.message }) + end + end + + private + + def process_plan_change + # Calculate proration + proration_result = ProrationCalculatorService.call( + subscription: subscription, + new_plan: new_plan, + change_date: effective_date, + proration_type: proration_type + ) + + unless proration_result.success? + raise StandardError, "Proration calculation failed: #{proration_result.error}" + end + + proration_details = proration_result.data[:proration_details] + + if proration_type == 'immediate' + execute_immediate_change(proration_details) + else + schedule_end_of_period_change(proration_details) + end + end + + def execute_immediate_change(proration_details) + # Create subscription change record + change_record = create_subscription_change_record(proration_details) + + # Process proration billing if needed + if proration_details[:net_amount_cents] > 0 + process_proration_billing(proration_details, change_record) + elsif proration_details[:net_amount_cents] < 0 + apply_account_credit(proration_details[:credit_applied], change_record) + end + + # Update subscription + update_subscription_for_new_plan(proration_details) + + # Send notification + send_plan_change_notification(change_record) + + success({ + subscription: subscription_data, + change_record: change_record_data(change_record), + proration_details: proration_details + }) + end + + def schedule_end_of_period_change(proration_details) + # Create scheduled change record + change_record = create_subscription_change_record(proration_details, scheduled: true) + + # Schedule the change + WorkerJobService.enqueue_billing_job('scheduled_plan_change', { + subscription_id: subscription.id, + new_plan_id: new_plan.id, + change_record_id: change_record.id, + scheduled_for: proration_details[:effective_date] + }) + + success({ + subscription: subscription_data, + change_record: change_record_data(change_record), + proration_details: proration_details, + scheduled_change: true + }) + end + + def create_subscription_change_record(proration_details, scheduled: false) + subscription.subscription_changes.create!( + from_plan: subscription.plan, + to_plan: new_plan, + change_type: change_type, + effective_date: scheduled ? proration_details[:effective_date] : Time.current, + proration_details: proration_details, + status: scheduled ? 'scheduled' : 'completed', + net_amount_cents: proration_details[:net_amount_cents] + ) + end + + def process_proration_billing(proration_details, change_record) + # Create proration invoice + invoice = Invoice.create!( + subscription: subscription, + invoice_type: 'proration', + total_cents: proration_details[:net_amount_cents], + due_date: Time.current, + description: "Plan change from #{subscription.plan.name} to #{new_plan.name}", + metadata: { change_record_id: change_record.id } + ) + + # Add line items + invoice.line_items.create!([ + { + description: "Credit for unused time on #{subscription.plan.name}", + amount_cents: -proration_details[:unused_credit_cents], + quantity: 1 + }, + { + description: "Prorated charge for #{new_plan.name}", + amount_cents: proration_details[:prorated_charge_cents], + quantity: 1 + } + ]) + + # Process payment immediately + WorkerJobService.enqueue_billing_job('process_proration_payment', { + invoice_id: invoice.id, + change_record_id: change_record.id + }) + end + + def apply_account_credit(credit_amount, change_record) + subscription.account.increment!(:account_credit_cents, credit_amount) + + # Log credit application + subscription.account.account_credits.create!( + amount_cents: credit_amount, + source: 'plan_change', + description: "Credit from plan change", + reference_id: change_record.id + ) + end + + def update_subscription_for_new_plan(proration_details) + subscription.update!( + plan: new_plan, + amount_cents: new_plan.price_cents, + last_plan_change_at: Time.current + ) + end + + def send_plan_change_notification(change_record) + WorkerJobService.enqueue_billing_job('plan_change_notification', { + subscription_id: subscription.id, + change_record_id: change_record.id, + change_type: change_type + }) + end + + def subscription_data + { + id: subscription.id, + plan: { + id: subscription.plan.id, + name: subscription.plan.name, + price_cents: subscription.plan.price_cents + }, + status: subscription.status, + updated_at: subscription.updated_at.iso8601 + } + end + + def change_record_data(change_record) + { + id: change_record.id, + change_type: change_record.change_type, + effective_date: change_record.effective_date.iso8601, + status: change_record.status, + net_amount_cents: change_record.net_amount_cents + } + end +end +``` + +### 4. Dunning and Recovery System (MANDATORY) + +#### Dunning Process Service +```ruby +# app/services/dunning_process_service.rb +class DunningProcessService < BaseService + attribute :subscription, Subscription + attribute :dunning_stage, Integer, default: 1 + attribute :final_attempt, Boolean, default: false + + DUNNING_STAGES = { + 1 => { days: 1, action: 'gentle_reminder' }, + 2 => { days: 3, action: 'payment_reminder' }, + 3 => { days: 7, action: 'urgent_notice' }, + 4 => { days: 14, action: 'final_warning' }, + 5 => { days: 21, action: 'suspension_notice' }, + 6 => { days: 30, action: 'cancellation_notice' } + }.freeze + + def call + return failure("Subscription not in dunning-eligible state") unless subscription.past_due? + return failure("Invalid dunning stage") unless DUNNING_STAGES.key?(dunning_stage) + + begin + process_dunning_stage + rescue StandardError => e + Rails.logger.error "Dunning process failed: #{e.message}" + failure("Dunning process failed", { error: e.message }) + end + end + + private + + def process_dunning_stage + stage_config = DUNNING_STAGES[dunning_stage] + + # Update dunning status + update_dunning_status(stage_config) + + # Execute stage action + case stage_config[:action] + when 'gentle_reminder' + send_gentle_reminder + when 'payment_reminder' + send_payment_reminder_and_retry + when 'urgent_notice' + send_urgent_notice_and_retry + when 'final_warning' + send_final_warning_and_retry + when 'suspension_notice' + suspend_subscription + when 'cancellation_notice' + cancel_subscription + end + + # Schedule next stage if not final + schedule_next_stage unless final_stage? + + success({ + subscription: subscription_data, + dunning_stage: dunning_stage, + action_taken: stage_config[:action], + next_action_date: next_stage_date&.iso8601 + }) + end + + def update_dunning_status(stage_config) + subscription.update!( + dunning_stage: dunning_stage, + last_dunning_action: stage_config[:action], + last_dunning_date: Time.current + ) + end + + def send_gentle_reminder + WorkerJobService.enqueue_billing_job('dunning_gentle_reminder', { + subscription_id: subscription.id, + stage: dunning_stage + }) + end + + def send_payment_reminder_and_retry + # Send notification + WorkerJobService.enqueue_billing_job('dunning_payment_reminder', { + subscription_id: subscription.id, + stage: dunning_stage + }) + + # Attempt payment retry + retry_payment + end + + def send_urgent_notice_and_retry + # Send urgent notice + WorkerJobService.enqueue_billing_job('dunning_urgent_notice', { + subscription_id: subscription.id, + stage: dunning_stage, + suspension_warning: true + }) + + # Final payment retry attempt + retry_payment + end + + def send_final_warning_and_retry + # Send final warning + WorkerJobService.enqueue_billing_job('dunning_final_warning', { + subscription_id: subscription.id, + stage: dunning_stage, + final_attempt: true + }) + + # Last chance payment retry + retry_payment + end + + def suspend_subscription + if subscription.may_suspend? + subscription.suspend! + + # Notify of suspension + WorkerJobService.enqueue_billing_job('subscription_suspended_notification', { + subscription_id: subscription.id, + reason: 'payment_failure', + dunning_stage: dunning_stage + }) + end + end + + def cancel_subscription + if subscription.may_cancel? + subscription.cancel! + + # Final cancellation notice + WorkerJobService.enqueue_billing_job('subscription_cancelled_notification', { + subscription_id: subscription.id, + reason: 'payment_failure', + final_dunning_stage: true + }) + + # Archive subscription data + WorkerJobService.enqueue_billing_job('archive_cancelled_subscription', { + subscription_id: subscription.id + }) + end + end + + def retry_payment + # Attempt to process payment again + WorkerJobService.enqueue_billing_job('dunning_payment_retry', { + subscription_id: subscription.id, + dunning_stage: dunning_stage, + retry_type: 'dunning' + }) + end + + def schedule_next_stage + next_stage = dunning_stage + 1 + return unless DUNNING_STAGES.key?(next_stage) + + WorkerJobService.enqueue_billing_job('dunning_process_continue', { + subscription_id: subscription.id, + dunning_stage: next_stage, + scheduled_for: next_stage_date.iso8601 + }) + end + + def next_stage_date + return nil if final_stage? + + next_stage = dunning_stage + 1 + return nil unless DUNNING_STAGES.key?(next_stage) + + Time.current + DUNNING_STAGES[next_stage][:days].days + end + + def final_stage? + dunning_stage >= DUNNING_STAGES.keys.max + end + + def subscription_data + { + id: subscription.id, + status: subscription.status, + dunning_stage: subscription.dunning_stage, + last_dunning_action: subscription.last_dunning_action, + past_due_since: subscription.past_due_since&.iso8601 + } + end +end +``` + +### 5. Invoice Generation System (MANDATORY) + +#### Invoice Generation Service +```ruby +# app/services/invoice_generation_service.rb +class InvoiceGenerationService < BaseService + attribute :subscription, Subscription + attribute :invoice_type, String, default: 'recurring' + attribute :billing_period_start, DateTime + attribute :billing_period_end, DateTime + attribute :due_date, DateTime + attribute :line_items, Array, default: [] + + INVOICE_TYPES = %w[recurring activation proration one_time].freeze + + validates :subscription, :invoice_type, presence: true + validates :invoice_type, inclusion: { in: INVOICE_TYPES } + + def call + return failure("Invalid parameters", errors.full_messages) unless valid? + + begin + ActiveRecord::Base.transaction do + create_invoice + end + rescue StandardError => e + Rails.logger.error "Invoice generation failed: #{e.message}" + failure("Invoice generation failed", { error: e.message }) + end + end + + private + + def create_invoice + invoice = build_invoice + + # Add line items based on invoice type + add_line_items_to_invoice(invoice) + + # Calculate totals + calculate_invoice_totals(invoice) + + # Generate invoice number + generate_invoice_number(invoice) + + # Save invoice + invoice.save! + + # Generate PDF if needed + schedule_pdf_generation(invoice) if should_generate_pdf? + + # Send invoice notification + send_invoice_notification(invoice) + + success({ + invoice: invoice_data(invoice), + line_items: invoice.line_items.map { |li| line_item_data(li) } + }) + end + + def build_invoice + Invoice.new( + subscription: subscription, + account: subscription.account, + invoice_type: invoice_type, + billing_period_start: billing_period_start || subscription.current_period_start, + billing_period_end: billing_period_end || subscription.current_period_end, + due_date: due_date || calculate_due_date, + currency: subscription.plan.currency, + status: 'draft', + issued_at: Time.current + ) + end + + def add_line_items_to_invoice(invoice) + case invoice_type + when 'recurring' + add_recurring_line_items(invoice) + when 'activation' + add_activation_line_items(invoice) + when 'proration' + add_proration_line_items(invoice) + when 'one_time' + add_custom_line_items(invoice) + end + end + + def add_recurring_line_items(invoice) + plan = subscription.plan + + invoice.line_items.build( + description: "#{plan.name} - #{format_billing_period(invoice)}", + quantity: 1, + unit_price_cents: plan.price_cents, + amount_cents: plan.price_cents, + plan_id: plan.id + ) + + # Add any usage-based charges + add_usage_charges(invoice) if plan.has_usage_billing? + + # Add any applicable taxes + add_tax_line_items(invoice) + + # Apply any discounts + apply_discounts(invoice) + end + + def add_activation_line_items(invoice) + plan = subscription.plan + + # Pro-rated charge for activation + activation_amount = calculate_activation_amount + + invoice.line_items.build( + description: "#{plan.name} - Activation (#{format_billing_period(invoice)})", + quantity: 1, + unit_price_cents: activation_amount, + amount_cents: activation_amount, + plan_id: plan.id + ) + + # Setup fees if applicable + if plan.setup_fee_cents > 0 + invoice.line_items.build( + description: "Setup Fee - #{plan.name}", + quantity: 1, + unit_price_cents: plan.setup_fee_cents, + amount_cents: plan.setup_fee_cents + ) + end + + add_tax_line_items(invoice) + end + + def add_proration_line_items(invoice) + # Custom line items should be provided for proration invoices + line_items.each do |item| + invoice.line_items.build( + description: item[:description], + quantity: item[:quantity] || 1, + unit_price_cents: item[:unit_price_cents], + amount_cents: item[:amount_cents] || (item[:quantity] * item[:unit_price_cents]) + ) + end + + add_tax_line_items(invoice) + end + + def add_usage_charges(invoice) + # Calculate usage for the billing period + usage_service = UsageCalculationService.call( + subscription: subscription, + period_start: invoice.billing_period_start, + period_end: invoice.billing_period_end + ) + + if usage_service.success? && usage_service.data[:total_usage_cents] > 0 + invoice.line_items.build( + description: "Usage charges - #{format_billing_period(invoice)}", + quantity: usage_service.data[:total_units], + unit_price_cents: usage_service.data[:unit_price_cents], + amount_cents: usage_service.data[:total_usage_cents], + usage_data: usage_service.data[:usage_breakdown] + ) + end + end + + def add_tax_line_items(invoice) + # Calculate applicable taxes based on account location + tax_service = TaxCalculationService.call( + account: subscription.account, + invoice: invoice + ) + + if tax_service.success? && tax_service.data[:total_tax_cents] > 0 + tax_service.data[:tax_breakdown].each do |tax_item| + invoice.line_items.build( + description: tax_item[:description], + quantity: 1, + unit_price_cents: tax_item[:amount_cents], + amount_cents: tax_item[:amount_cents], + line_item_type: 'tax', + tax_rate: tax_item[:rate] + ) + end + end + end + + def apply_discounts(invoice) + # Apply any active discounts or coupons + discount_service = DiscountApplicationService.call( + subscription: subscription, + invoice: invoice + ) + + if discount_service.success? && discount_service.data[:total_discount_cents] > 0 + invoice.line_items.build( + description: discount_service.data[:description], + quantity: 1, + unit_price_cents: -discount_service.data[:total_discount_cents], + amount_cents: -discount_service.data[:total_discount_cents], + line_item_type: 'discount' + ) + end + end + + def calculate_invoice_totals(invoice) + subtotal = invoice.line_items.where.not(line_item_type: 'tax').sum(:amount_cents) + tax_total = invoice.line_items.where(line_item_type: 'tax').sum(:amount_cents) + total = subtotal + tax_total + + invoice.assign_attributes( + subtotal_cents: subtotal, + tax_cents: tax_total, + total_cents: total + ) + end + + def generate_invoice_number(invoice) + prefix = case invoice_type + when 'recurring' then 'INV' + when 'activation' then 'ACT' + when 'proration' then 'PRO' + when 'one_time' then 'OT' + end + + sequence = Invoice.where( + "invoice_number LIKE ?", + "#{prefix}-#{Date.current.strftime('%Y%m')}-%" + ).count + 1 + + invoice.invoice_number = "#{prefix}-#{Date.current.strftime('%Y%m')}-#{sequence.to_s.rjust(4, '0')}" + end + + def calculate_due_date + # Default due date is immediate for recurring, 30 days for others + case invoice_type + when 'recurring' + Time.current + else + 30.days.from_now + end + end + + def calculate_activation_amount + return subscription.plan.price_cents if billing_period_start.nil? + + # Prorate based on activation date within billing period + total_days = (billing_period_end - billing_period_start).to_i / 1.day + remaining_days = (billing_period_end - Time.current).to_i / 1.day + + return 0 if remaining_days <= 0 + + daily_rate = subscription.plan.price_cents.to_f / total_days + (daily_rate * remaining_days).round + end + + def format_billing_period(invoice) + start_date = invoice.billing_period_start.strftime('%b %d, %Y') + end_date = invoice.billing_period_end.strftime('%b %d, %Y') + "#{start_date} - #{end_date}" + end + + def should_generate_pdf? + invoice_type != 'proration' || subscription.account.pdf_invoices_enabled? + end + + def schedule_pdf_generation(invoice) + WorkerJobService.enqueue_billing_job('generate_invoice_pdf', { + invoice_id: invoice.id + }) + end + + def send_invoice_notification(invoice) + WorkerJobService.enqueue_billing_job('send_invoice_notification', { + invoice_id: invoice.id, + invoice_type: invoice_type + }) + end + + def invoice_data(invoice) + { + id: invoice.id, + invoice_number: invoice.invoice_number, + status: invoice.status, + total_cents: invoice.total_cents, + due_date: invoice.due_date.iso8601, + created_at: invoice.created_at.iso8601 + } + end + + def line_item_data(line_item) + { + description: line_item.description, + quantity: line_item.quantity, + unit_price_cents: line_item.unit_price_cents, + amount_cents: line_item.amount_cents + } + end +end +``` + +## Development Commands + +### Billing Engine Management +```bash +# Generate billing models and services +rails generate model SubscriptionChange subscription:references from_plan:references to_plan:references +rails generate model Invoice subscription:references account:references +rails generate model InvoiceLineItem invoice:references + +# Run billing-related migrations +rails db:migrate + +# Test billing processes in console +rails console +> subscription = Subscription.first +> SubscriptionLifecycleService.call(subscription: subscription, action: 'activate') +> ProrationCalculatorService.call(subscription: subscription, new_plan: Plan.last) + +# Monitor background billing jobs +bundle exec sidekiq -C config/sidekiq.yml -e development +``` + +### Testing Billing Logic +```bash +# Run billing-specific tests +bundle exec rspec spec/services/billing/ +bundle exec rspec spec/models/subscription_spec.rb +bundle exec rspec spec/jobs/billing/ + +# Test renewal processes +rails runner "SubscriptionRenewalService.call(subscription: Subscription.due_for_renewal.first)" + +# Test dunning processes +rails runner "DunningProcessService.call(subscription: Subscription.past_due.first)" +``` + +## Integration Points + +### Billing Engine Developer Coordinates With: +- **Payment Integration Specialist**: Payment processing, webhook handling +- **Background Job Engineer**: Job scheduling, retry mechanisms +- **Data Modeler**: Subscription and billing data models +- **API Developer**: Billing endpoint implementation +- **Notification Engineer**: Billing notifications, dunning emails + +## Quick Reference + +### Billing Process Flow +1. **Subscription Creation** → Activation invoice → Payment processing +2. **Renewal Processing** → Generate invoice → Process payment → Update billing cycle +3. **Plan Changes** → Calculate proration → Process billing → Update subscription +4. **Failed Payments** → Dunning process → Retries → Suspension/Cancellation +5. **Invoice Generation** → Line items → Totals → PDF → Notifications + +### Key Service Classes +- `SubscriptionLifecycleService` - Manage subscription states +- `SubscriptionRenewalService` - Handle automated renewals +- `ProrationCalculatorService` - Calculate plan change billing +- `DunningProcessService` - Handle failed payment recovery +- `InvoiceGenerationService` - Create and format invoices diff --git a/docs/backend/DATABASE_SCHEMA_REFERENCE.md b/docs/backend/DATABASE_SCHEMA_REFERENCE.md new file mode 100644 index 000000000..8394a5ff3 --- /dev/null +++ b/docs/backend/DATABASE_SCHEMA_REFERENCE.md @@ -0,0 +1,168 @@ +# Database Schema Reference + +396 tables across 10 model namespaces, all using UUIDv7 primary keys on PostgreSQL. + +--- + +## Model Namespaces + +### Top-Level Models (~120) + +Core platform models not in a namespace: + +| Model | Description | +|-------|-------------| +| `User` | Platform users with authentication and permissions | +| `Account` | Multi-tenant account (one per organization) | +| `Role` | Permission grouping (system.admin, account.manager, etc.) | +| `Permission` | Individual permission (543 total) | +| `RolePermission` | Role-to-permission join table | +| `Plan` | Subscription plan with features/limits | +| `Subscription` | Account subscription (AASM state machine: 8 states) | +| `Invoice` | Billing invoices with line items | +| `Payment` | Payment records (Stripe, PayPal) | +| `Invitation` | User invitations with email workflow | +| `AuditLog` | Comprehensive activity tracking | +| `Notification` | User notifications | +| `ApiKey` / `ApiKeyUsage` | API key management and usage tracking | +| `AdminSetting` | System configuration key-value store | +| `BlacklistedToken` / `JwtBlacklist` | Token revocation | +| `PasswordHistory` | Password reuse prevention | +| `Page` | CMS content pages | +| `OauthApplication` | OAuth2 provider applications | +| `ImpersonationSession` | Admin impersonation tracking | +| `McpServer` / `McpTool` / `McpSession` / `McpToolExecution` | MCP protocol infrastructure | +| `CommunityAgent` / `CommunityAgentRating` / `CommunityAgentReport` | Agent marketplace | +| `ExternalAgent` / `FederationPartner` | A2A external agents | +| `ReportRequest` | Async report generation | +| `EmailDelivery` | Email delivery tracking | +| `BackgroundJob` | Job status tracking | +| `BatchWorkflowRun` | Batch workflow execution | + +### Ai:: Namespace (135 models) + +The largest namespace — covers the entire AI platform. + +| Area | Models | Examples | +|------|--------|----------| +| Agents | 15+ | `Agent`, `AgentExecution`, `AgentExecutionStep`, `AgentCapability`, `AgentConfiguration` | +| Teams | 10+ | `AgentTeam`, `AgentTeamMember`, `TeamExecution`, `TeamChannel`, `TeamMessage` | +| Workflows | 15+ | `Workflow`, `WorkflowRun`, `WorkflowNode`, `WorkflowEdge`, `WorkflowNodeExecution` | +| Providers | 8+ | `Provider`, `ProviderModel`, `ModelRoutingRule`, `ProviderHealthCheck` | +| Knowledge | 12+ | `KnowledgeGraphNode`, `KnowledgeGraphRelationship`, `CompoundLearning`, `SharedKnowledge` | +| Memory | 8+ | `MemoryEntry`, `MemoryPool`, `ContextEntry`, `ContextGroup` | +| Skills | 5+ | `Skill`, `SkillExecution`, `AgentSkill` | +| Tools | 5+ | `Tool`, `ToolExecution`, `ToolCategory` | +| Code Factory | 10+ | `CodeFactoryRun`, `CodeFactoryContract`, `CodeFactoryTask` | +| Missions | 5+ | `Mission`, `MissionStage`, `MissionArtifact` | +| Conversations | 5+ | `Conversation`, `Message`, `Attachment` | +| Monitoring | 5+ | `CostTracking`, `UsageMetric`, `BudgetAlert` | +| Templates | 5+ | `SystemPromptTemplate`, `WorkflowTemplate` | +| Trust & Safety | 5+ | `TrustScore`, `Guardrail`, `GuardrailEvaluation` | + +### Devops:: Namespace (41 models) + +| Area | Models | Examples | +|------|--------|----------| +| Pipelines | 10+ | `Pipeline`, `PipelineRun`, `PipelineStep`, `PipelineStepExecution` | +| Git | 10+ | `GitProvider`, `GitRepository`, `GitRunner`, `GitPipelineJob` | +| Docker | 8+ | `DockerHost`, `DockerContainer`, `SwarmService`, `SwarmStack` | +| Deployments | 5+ | `Deployment`, `DeploymentTarget`, `DeploymentEnvironment` | +| Templates | 5+ | `IntegrationTemplate`, `ContainerTemplate` | + +### KnowledgeBase:: Namespace (8 models) + +| Model | Description | +|-------|-------------| +| `Article` | KB articles with Markdown content | +| `Category` | Article categorization | +| `Tag` | Article tagging | +| `ArticleTag` | Article-to-tag join | +| `Comment` | Article comments | +| `Attachment` | Article file attachments | +| `ArticleView` | View tracking | +| `Workflow` | Article workflow states | + +### Chat:: Namespace (5 models) + +| Model | Description | +|-------|-------------| +| `Conversation` | Chat conversation container | +| `Message` | Individual messages | +| `Attachment` | Message attachments | +| `Session` | Active chat sessions | +| `Participant` | Conversation participants | + +### FileManagement:: Namespace (7 models) + +| Model | Description | +|-------|-------------| +| `FileUpload` | Uploaded file records | +| `StorageBackend` | Storage provider configuration | +| `FileVersion` | File versioning | +| `FileShare` | Shared file access | +| `VirusScanResult` | Antivirus scan results | +| `FileQuota` | Storage quota tracking | +| `FileAuditLog` | File access audit trail | + +### Account:: Namespace (3 models) + +| Model | Description | +|-------|-------------| +| `Delegation` | Cross-account access delegation | +| `Setting` | Account-specific settings | +| `Feature` | Account feature flags | + +### DataManagement:: Namespace (3 models) + +| Model | Description | +|-------|-------------| +| `RetentionPolicy` | Data retention rules | +| `SanitizationRule` | PII sanitization | +| `DataExport` | GDPR data export records | + +### Database:: Namespace (2 models) + +| Model | Description | +|-------|-------------| +| `Connection` | External database connections | +| `QueryHistory` | Query execution history | + +### Monitoring:: Namespace (2 models) + +| Model | Description | +|-------|-------------| +| `HealthCheck` | Service health check records | +| `ServiceStatus` | Service availability status | + +### Shared:: Namespace (1 model) + +| Model | Description | +|-------|-------------| +| `FeatureGate` | Enterprise feature gating | + +--- + +## Key Relationships + +``` +Account ──┬── User (many) ──── Role (many) ──── Permission (many) + ├── Subscription (one) ──── Plan + ├── Ai::Agent (many) ──── Ai::AgentExecution (many) + ├── Ai::Workflow (many) ──── Ai::WorkflowRun (many) + ├── Ai::AgentTeam (many) ──── Ai::AgentTeamMember (many) + ├── Devops::Pipeline (many) ──── Devops::PipelineRun (many) + └── Invoice (many) ──── Payment (many) +``` + +--- + +## Database Conventions + +- **Primary keys**: UUIDv7 (chronologically sortable) +- **Foreign keys**: `t.references` with type `:uuid` (index included automatically) +- **Namespaced FK prefix**: `Ai::` → `ai_`, `Devops::` → `devops_`, `BaaS::` → `baas_` +- **Timestamps**: `created_at`, `updated_at` on all tables +- **Soft delete**: `discarded_at` on applicable models (using Discard gem) +- **JSON columns**: Lambda defaults (`default: -> { {} }`) +- **Vectors**: pgvector with HNSW indexes for embedding columns diff --git a/docs/backend/DATA_MODELER_SPECIALIST.md b/docs/backend/DATA_MODELER_SPECIALIST.md new file mode 100644 index 000000000..b3385f54f --- /dev/null +++ b/docs/backend/DATA_MODELER_SPECIALIST.md @@ -0,0 +1,618 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# Data Modeler Specialist Guide + +## Role & Responsibilities + +The Data Modeler specializes in database architecture and ActiveRecord model design for Powernode's subscription platform. + +### Core Responsibilities +- Designing database schema and relationships +- Creating ActiveRecord models with validations +- Implementing model associations and scopes +- Handling data migrations and versioning +- Optimizing database queries and indexes + +### Key Focus Areas +- Subscription business logic: User, Subscription, Plan, Invoice, Payment models +- UUID primary key strategy implementation +- Database relationship optimization +- Data integrity and audit logging + +## Database Architecture Standards + +### 1. UUID Strategy (CRITICAL) +**MANDATORY**: All models use UUID primary keys. + +#### Current Implementation +```ruby +# Current tables use string with limit +string :id, limit: 36, primary_key: true + +# New tables should use gen_random_uuid() +string :id, limit: 36, primary_key: true, default: -> { 'gen_random_uuid()' } +``` + +#### Database Extensions +```ruby +# Required extensions +enable_extension 'pgcrypto' +enable_extension 'uuid-ossp' +``` + +### 2. Standard Model Structure (CRITICAL) + +#### Discovered Model Organization Pattern +**MANDATORY**: All models must follow this exact structure order discovered in platform analysis. + +```ruby +class User < ApplicationRecord + # 1. Authentication (if applicable) + has_secure_password + + # 2. Concerns (modular functionality) + include PasswordSecurity + + # 3. Associations + belongs_to :account + has_many :user_roles, dependent: :destroy + has_many :roles, through: :user_roles + has_many :audit_logs, dependent: :nullify + + # 4. Validations + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :status, inclusion: { in: %w[active inactive suspended] } + validates :first_name, :last_name, presence: true + + # 5. Scopes + scope :active, -> { where(status: 'active') } + scope :recent, -> { order(created_at: :desc) } + scope :with_roles, -> { includes(:roles) } + + # 6. Callbacks + before_validation :normalize_email + after_create :send_welcome_email + after_update :audit_changes + + # 7. Instance methods + def full_name + "#{first_name} #{last_name}".strip + end + + def has_permission?(permission) + all_permissions.include?(permission) + end + + def all_permissions + @all_permissions ||= roles.flat_map(&:permissions).map(&:name).uniq + end + + private + + # 8. Private methods + def normalize_email + self.email = email&.downcase&.strip + end + + def send_welcome_email + # Implementation + end +end +``` + +#### Model Concern Pattern (Discovered) +**CRITICAL**: Use concerns for cross-cutting functionality discovered in platform analysis. + +```ruby +# app/models/concerns/password_security.rb +module PasswordSecurity + extend ActiveSupport::Concern + + included do + has_many :password_histories, dependent: :destroy + validates :password, length: { minimum: 12 }, on: :create + validates :password, confirmation: true, if: :password_changed? + validate :password_complexity + validate :password_not_recently_used + end + + class_methods do + def authenticate_with_lockout(email, password, max_attempts: 5) + user = find_by(email: email&.downcase) + return nil unless user + + if user.locked_out? + return nil + end + + if user.authenticate(password) + user.reset_failed_attempts + user + else + user.increment_failed_attempts(max_attempts) + nil + end + end + end + + def locked_out? + failed_attempts >= 5 && last_failed_attempt > 15.minutes.ago + end + + def reset_failed_attempts + update_columns(failed_attempts: 0, last_failed_attempt: nil) + end + + def increment_failed_attempts(max_attempts) + increment!(:failed_attempts) + update_column(:last_failed_attempt, Time.current) + + if failed_attempts >= max_attempts + # Send security notification + SecurityNotificationJob.perform_async(id, 'account_locked') + end + end + + private + + def password_complexity + return unless password.present? + + errors = [] + errors << 'must contain at least one uppercase letter' unless password.match?(/[A-Z]/) + errors << 'must contain at least one lowercase letter' unless password.match?(/[a-z]/) + errors << 'must contain at least one number' unless password.match?(/\d/) + errors << 'must contain at least one special character' unless password.match?(/[^A-Za-z0-9]/) + + errors.each { |error| self.errors.add(:password, error) } + end + + def password_not_recently_used + return unless password.present? + + recent_passwords = password_histories.order(created_at: :desc).limit(5) + recent_passwords.each do |history| + if BCrypt::Password.new(history.password_digest) == password + errors.add(:password, 'cannot be one of your last 5 passwords') + break + end + end + end +end +``` + +### 3. Core Data Models + +#### Account Model +```ruby +class Account < ApplicationRecord + # Primary subscription entity + has_many :users, dependent: :destroy + has_one :subscription, dependent: :destroy + has_many :payments, through: :subscription + has_many :invoices, through: :subscription + belongs_to :default_volume, class_name: 'Volume', optional: true + + validates :name, presence: true, length: { minimum: 2, maximum: 100 } + validates :status, inclusion: { in: %w[active suspended cancelled] } + + scope :active, -> { where(status: "active") } + scope :suspended, -> { where(status: "suspended") } +end +``` + +#### User Model +```ruby +class User < ApplicationRecord + belongs_to :account + has_many :user_roles, dependent: :destroy + has_many :roles, through: :user_roles + has_many :audit_logs, dependent: :nullify + + validates :email, presence: true, uniqueness: true + validates :password, length: { minimum: 12 }, allow_blank: true + + # Permission-based access control + def permissions + roles.joins(:role_permissions) + .joins(:permissions) + .pluck('permissions.name') + .uniq + end +end +``` + +#### Subscription Model +```ruby +class Subscription < ApplicationRecord + belongs_to :account + belongs_to :plan + has_many :payments, dependent: :destroy + has_many :invoices, dependent: :destroy + + validates :status, inclusion: { in: %w[active cancelled suspended] } + validates :current_period_start, :current_period_end, presence: true + + scope :active, -> { where(status: 'active') } + scope :cancelled, -> { where(status: 'cancelled') } +end +``` + +#### Plan Model +```ruby +class Plan < ApplicationRecord + has_many :subscriptions, dependent: :destroy + has_many :app_plans, dependent: :destroy + has_many :apps, through: :app_plans + + validates :name, presence: true + validates :price_cents, presence: true, numericality: { greater_than: 0 } + validates :billing_interval, inclusion: { in: %w[month year] } + + monetize :price_cents +end +``` + +### 4. Database Migration Standards + +#### Migration File Structure +```ruby +# frozen_string_literal: true + +class CreateModelName < ActiveRecord::Migration[8.0] + def change + create_table :model_names, id: false do |t| + t.string :id, limit: 36, primary_key: true, default: -> { 'gen_random_uuid()' } + + # Foreign keys with UUID + t.string :account_id, limit: 36, null: false + t.index :account_id + + # Standard fields + t.string :name, null: false + t.string :status, default: 'active' + + # Audit fields + t.timestamps null: false + + # Constraints + t.foreign_key :accounts, type: :string + end + + add_index :model_names, :status + add_index :model_names, :created_at + end +end +``` + +#### Database Reset Commands +```bash +# Complete database reset with worker token update +cd server && rails db:drop db:create db:migrate db:seed + +# Update worker token after reset +rails runner "worker = Worker.find_by(name: 'Powernode System Worker'); +if worker && worker.token.present? + File.write('worker/.env', File.read('worker/.env').gsub(/^WORKER_TOKEN=.*$/, \"WORKER_TOKEN=#{worker.token}\")) + puts \"✅ Updated worker/.env with system worker token: #{worker.token[0..10]}...\" +else + puts \"❌ No system worker token found - check seeds.rb\" +end" +``` + +### 5. Permission System Data Model + +#### Permission-Based Access Control +```ruby +class Permission < ApplicationRecord + has_many :role_permissions, dependent: :destroy + has_many :roles, through: :role_permissions + + validates :name, presence: true, uniqueness: true + validates :resource, presence: true + validates :action, presence: true + + # Format: resource.action (e.g., users.create, billing.read) + def full_name + "#{resource}.#{action}" + end +end + +class Role < ApplicationRecord + has_many :role_permissions, dependent: :destroy + has_many :permissions, through: :role_permissions + has_many :user_roles, dependent: :destroy + has_many :users, through: :user_roles + + validates :name, presence: true, uniqueness: true +end + +class UserRole < ApplicationRecord + belongs_to :user + belongs_to :role + + validates :user_id, uniqueness: { scope: :role_id } +end +``` + +### 6. Audit Logging Data Model + +#### Audit Log Implementation +```ruby +class AuditLog < ApplicationRecord + belongs_to :user, optional: true + belongs_to :account + + validates :action, presence: true + validates :resource_type, presence: true + validates :details, presence: true + + scope :recent, -> { order(created_at: :desc) } + scope :for_resource, ->(type) { where(resource_type: type) } + + # JSON storage for flexible audit data + store_accessor :details, :changes, :metadata, :ip_address +end +``` + +### 7. Payment & Billing Data Models + +#### Payment Model +```ruby +class Payment < ApplicationRecord + belongs_to :subscription + belongs_to :payment_method, optional: true + + validates :amount_cents, presence: true, numericality: { greater_than: 0 } + validates :currency, presence: true + validates :status, inclusion: { in: %w[pending succeeded failed refunded] } + + monetize :amount_cents + + scope :succeeded, -> { where(status: 'succeeded') } + scope :failed, -> { where(status: 'failed') } +end + +class PaymentMethod < ApplicationRecord + belongs_to :account + has_many :payments, dependent: :destroy + + validates :provider, inclusion: { in: %w[stripe paypal] } + validates :method_type, inclusion: { in: %w[card bank_account paypal] } + + scope :active, -> { where(active: true) } +end +``` + +#### Invoice Model +```ruby +class Invoice < ApplicationRecord + belongs_to :subscription + has_many :invoice_line_items, dependent: :destroy + has_one :payment, dependent: :nullify + + validates :invoice_number, presence: true, uniqueness: true + validates :status, inclusion: { in: %w[draft open paid void] } + validates :total_cents, presence: true + + monetize :total_cents + + before_create :generate_invoice_number + + private + + def generate_invoice_number + self.invoice_number = "INV-#{Time.current.strftime('%Y%m')}-#{SecureRandom.hex(4).upcase}" + end +end +``` + +### 8. Marketplace Data Models + +#### App Model +```ruby +class App < ApplicationRecord + has_many :app_plans, dependent: :destroy + has_many :plans, through: :app_plans + has_many :app_subscriptions, dependent: :destroy + has_many :app_endpoints, dependent: :destroy + has_many :app_webhooks, dependent: :destroy + + validates :name, presence: true + validates :slug, presence: true, uniqueness: true + validates :status, inclusion: { in: %w[draft published archived] } + + scope :published, -> { where(status: 'published') } +end + +class AppSubscription < ApplicationRecord + belongs_to :app + belongs_to :account + belongs_to :plan + + validates :status, inclusion: { in: %w[active cancelled suspended] } + + scope :active, -> { where(status: 'active') } +end +``` + +### 9. Worker & System Models + +#### Worker Model +```ruby +class Worker < ApplicationRecord + belongs_to :account, optional: true + has_many :worker_activities, dependent: :destroy + + validates :name, presence: true + validates :token, presence: true, uniqueness: true + validates :status, inclusion: { in: %w[active inactive error] } + + before_create :generate_token + + private + + def generate_token + self.token = SecureRandom.hex(32) + end +end + +class WorkerActivity < ApplicationRecord + belongs_to :worker + + validates :activity_type, presence: true + validates :status, inclusion: { in: %w[started completed failed] } + + scope :recent, -> { order(created_at: :desc).limit(100) } +end +``` + +### 10. Query Optimization Standards + +#### Index Strategy +```ruby +# Performance-critical indexes +add_index :subscriptions, [:account_id, :status] +add_index :payments, [:subscription_id, :created_at] +add_index :audit_logs, [:account_id, :created_at] +add_index :users, [:account_id, :email] + +# Composite indexes for common queries +add_index :user_roles, [:user_id, :role_id], unique: true +add_index :role_permissions, [:role_id, :permission_id], unique: true +``` + +#### Query Patterns +```ruby +# Use includes for N+1 prevention +accounts = Account.includes(:users, :subscription).active + +# Use joins for filtering +users_with_permissions = User.joins(roles: :permissions) + .where(permissions: { name: 'users.manage' }) + +# Use scopes for reusable queries +recent_payments = Payment.joins(:subscription) + .where(subscriptions: { status: 'active' }) + .succeeded + .recent +``` + +## Development Commands + +### Database Management +```bash +# Create and migrate +rails db:create db:migrate db:seed + +# Reset database with worker token update +rails db:drop db:create db:migrate db:seed && rails runner "worker = Worker.find_by(name: 'Powernode System Worker'); if worker && worker.token.present?; File.write('worker/.env', File.read('worker/.env').gsub(/^WORKER_TOKEN=.*$/, \"WORKER_TOKEN=#{worker.token}\")); puts \"✅ Updated worker token\"; end" + +# Generate migration +rails generate migration CreateModelName + +# Check schema +rails db:schema:dump +``` + +### Model Validation +```bash +# Console testing +rails console +> Account.create!(name: "Test", status: "active") +> User.joins(roles: :permissions).where(permissions: { name: 'users.read' }) +``` + +## Integration Points + +### Data Modeler Coordinates With: +- **Rails Architect**: Database configuration, migration strategy +- **API Developer**: Model serialization, data validation +- **Billing Engine Developer**: Subscription lifecycle data flow +- **Payment Integration Specialist**: Payment and invoice data models +- **Backend Test Engineer**: Model testing, factory definitions +- **Analytics Engineer**: Reporting data models, KPI calculations + +## Quick Reference + +### Model Generation Template +```ruby +# frozen_string_literal: true + +class ModelName < ApplicationRecord + # Associations + belongs_to :account + has_many :related_models, dependent: :destroy + + # Validations + validates :name, presence: true + validates :status, inclusion: { in: %w[active inactive] } + + # Scopes + scope :active, -> { where(status: 'active') } + + # Callbacks + after_create :log_creation + + # Methods + def active? + status == 'active' + end + + private + + def log_creation + Rails.logger.info "#{self.class.name} created: #{id}" + end +end +``` + +### Migration Template +```ruby +# frozen_string_literal: true + +class CreateModelNames < ActiveRecord::Migration[8.0] + def change + create_table :model_names, id: false do |t| + t.string :id, limit: 36, primary_key: true, default: -> { 'gen_random_uuid()' } + t.string :account_id, limit: 36, null: false + t.string :name, null: false + t.string :status, default: 'active' + t.timestamps null: false + + t.index :account_id + t.index :status + t.foreign_key :accounts, type: :string + end + end +end +``` + +### Pattern Validation Commands +```bash +# Check model structure compliance (discovered pattern) +find server/app/models -name "*.rb" -exec grep -l "# 1\. Authentication\|# 2\. Concerns\|# 3\. Associations" {} \; | wc -l + +# Find models missing frozen_string_literal +grep -L "frozen_string_literal" server/app/models/**/*.rb + +# Check UUID primary key usage +grep -r "string :id, limit: 36" server/db/migrate/ | wc -l + +# Validate permission method implementation (critical pattern) +grep -r "def has_permission?" server/app/models/ +grep -r "def all_permissions" server/app/models/ + +# Find concern usage in models +grep -r "include.*Security\|include.*Concern" server/app/models/ | wc -l + +# Check proper association dependency declarations +grep -r "dependent: :destroy\|dependent: :nullify" server/app/models/ | wc -l + +# Validate proper validation patterns +grep -r "format: { with: URI::MailTo::EMAIL_REGEXP }" server/app/models/ +grep -r "inclusion: { in: %w\[" server/app/models/ | wc -l +``` diff --git a/docs/backend/NODE_EXECUTOR_REFERENCE.md b/docs/backend/NODE_EXECUTOR_REFERENCE.md new file mode 100644 index 000000000..835ad8692 --- /dev/null +++ b/docs/backend/NODE_EXECUTOR_REFERENCE.md @@ -0,0 +1,362 @@ +# Node Executor Reference + +**Complete reference for 45+ workflow node executors** + +**Version**: 3.0 | **Last Updated**: February 2026 + +--- + +## Overview + +Node executors are located in `server/app/services/mcp/node_executors/`. Each executor inherits from `Base` and implements the `perform_execution` method. There are 45+ executors across 8 categories. + +### Directory Structure + +``` +server/app/services/mcp/node_executors/ +├── base.rb # Base class for all executors +├── mcp_base.rb # Extended base for MCP nodes +│ +├── # Control Flow (8) +├── start.rb # Workflow entry point +├── end.rb # Workflow completion +├── condition.rb # Conditional branching +├── loop.rb # Collection iteration +├── split.rb # Parallel execution +├── merge.rb # Parallel join +├── delay.rb # Timed delay +├── scheduler.rb # Scheduled execution +│ +├── # AI/Agent (2) +├── ai_agent.rb # AI agent execution +├── sub_workflow.rb # Nested workflow execution +│ +├── # Integration (9) +├── api_call.rb # HTTP API requests +├── webhook.rb # Webhook handling +├── notification.rb # Notification dispatch +├── email.rb # Email sending +├── database.rb # Database operations +├── file.rb # File operations +├── file_upload.rb # File upload +├── file_download.rb # File download +├── file_transform.rb # File transformation +│ +├── # Content (9) +├── page_create.rb # Create CMS page +├── page_read.rb # Read CMS page +├── page_update.rb # Update CMS page +├── page_publish.rb # Publish CMS page +├── kb_article_create.rb # Create KB article +├── kb_article_read.rb # Read KB article +├── kb_article_update.rb # Update KB article +├── kb_article_publish.rb # Publish KB article +├── kb_article_search.rb # Search KB articles +│ +├── # DevOps (13) +├── ci_trigger.rb # Trigger CI pipeline +├── ci_wait_status.rb # Wait for CI status +├── ci_get_logs.rb # Get CI logs +├── ci_cancel.rb # Cancel CI run +├── git_branch.rb # Git branch operations +├── git_checkout.rb # Git checkout +├── git_commit_status.rb # Git commit status +├── git_create_check.rb # Create GitHub/Gitea check +├── git_comment.rb # Git comment +├── git_pull_request.rb # Pull request operations +├── deploy.rb # Deployment execution +├── run_tests.rb # Test execution +├── shell_command.rb # Shell command execution +│ +├── # MCP (4) +├── mcp_tool.rb # MCP tool execution +├── mcp_prompt.rb # MCP prompt execution +├── mcp_resource.rb # MCP resource access +├── integration_execute.rb # Integration execution +│ +├── # Utility (3) +├── transform.rb # Data transformation +├── human_approval.rb # Human approval workflow +└── validator.rb # Data validation +``` + +### Validators (10) + +Each node type has a corresponding validator in `server/app/services/ai/workflow_validators/`: + +``` +├── base_validator.rb +├── ai_agent_validator.rb +├── api_call_validator.rb +├── condition_validator.rb +├── delay_validator.rb +├── human_approval_validator.rb +├── loop_validator.rb +├── sub_workflow_validator.rb +├── transform_validator.rb +└── webhook_validator.rb +``` + +--- + +## Base Executor + +**File**: `server/app/services/mcp/node_executors/base.rb` + +```ruby +class Base + attr_reader :node, :node_execution, :node_context, :orchestrator + + def initialize(node:, node_execution:, node_context:, orchestrator:) + def execute # Main entry point + + protected + def perform_execution # Override in subclass + def input_data # Get input data for this node + def get_variable(name) # Get variable from context + def set_variable(name, value) # Set variable in context + def previous_results # Get previous node results + def configuration # Get node configuration + def log_info(message) + def log_debug(message) + def log_error(message) +end +``` + +### Output Format + +All executors return: + +```ruby +{ + output: , # REQUIRED + data: { ... }, # Optional + result: { ... }, # Optional + metadata: { # REQUIRED + node_id: @node.node_id, + node_type: "", + executed_at: Time.current.iso8601 + } +} +``` + +--- + +## Control Flow Nodes (8) + +### Start Node (`start.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `input_variables` | Hash | No | Initial workflow variables | +| `trigger_type` | String | No | manual, scheduled, webhook | + +### End Node (`end.rb`) + +Aggregates all node outputs into final result. + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `output_variable` | String | No | Variable for final result | + +### Condition Node (`condition.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `condition_type` | String | No | expression, comparison, exists | +| `condition` | String | Yes* | Expression to evaluate | +| `left_variable` / `right_variable` | String | Yes* | Comparison operands | +| `operator` | String | No | ==, !=, >, <, >=, <= | + +### Loop Node (`loop.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `iteration_source` | String | Yes | Path to collection | +| `item_variable` | String | No | Current item var (default: "item") | +| `max_iterations` | Integer | No | Max iterations (default: 1000) | +| `execution_mode` | String | No | serial, parallel | +| `break_on_error` | Boolean | No | Stop on first error (default: true) | + +### Split / Merge / Delay / Scheduler + +| Node | Key Config | +|------|-----------| +| Split | `branches` (Array), `wait_for_all` (Boolean) | +| Merge | `merge_strategy` (wait_all, first_complete) | +| Delay | `delay_seconds` (Integer), `delay_until` (ISO8601) | +| Scheduler | `schedule` (cron/ISO8601), `timezone` | + +--- + +## AI/Agent Nodes (2) + +### AI Agent Node (`ai_agent.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `agent_id` | String | Yes | AI agent ID | +| `prompt_template` | String | No | Prompt with `{{variables}}` | +| `input_mapping` | Hash | No | Variable to agent param mapping | +| `output_variable` | String | No | Store result | + +**Output includes:** agent response, model, cost, tokens, duration, execution ID. + +### Sub-Workflow Node (`sub_workflow.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `workflow_id` | String | Yes | Sub-workflow ID | +| `input_mapping` | Hash | No | Variable mapping | +| `wait_for_completion` | Boolean | No | Wait for sub-workflow | + +--- + +## Integration Nodes (9) + +### API Call Node (`api_call.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `url` | String | Yes | Target URL (supports `{{variables}}`) | +| `method` | String | No | GET, POST, PUT, PATCH, DELETE | +| `headers` | Hash | No | Request headers | +| `body` | Hash/String | No | Request body | +| `timeout_seconds` | Integer | No | Timeout (default: 30) | +| `retry_count` | Integer | No | Retries (max: 5) | +| `response_mapping` | String | No | Dot notation to extract value | + +### Other Integration Nodes + +| Node | Key Config | +|------|-----------| +| Webhook | `webhook_url`, `event_type`, `payload_template` | +| Notification | `channels` (email/slack/sms/push), `recipients`, `message` | +| Email | `to`, `subject`, `body`, `html_body`, `attachments` | +| Database | `operation` (query/insert/update/delete), `table`, `conditions` | +| File | `operation` (read/write/delete/copy), `path`, `content` | +| File Upload/Download | `path`, `destination`, `transform_type` | + +--- + +## Content Nodes (9) + +### Page Nodes + +`page_create.rb`, `page_read.rb`, `page_update.rb`, `page_publish.rb` + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `page_id` | String | Yes* | Page ID (read/update/publish) | +| `title` | String | Yes* | Page title (create) | +| `content` | String | No | Page content | +| `status` | String | No | draft, published | + +### KB Article Nodes + +`kb_article_create.rb`, `kb_article_read.rb`, `kb_article_update.rb`, `kb_article_publish.rb`, `kb_article_search.rb` + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `article_id` | String | Yes* | Article ID | +| `title` | String | Yes* | Article title (create) | +| `query` | String | Yes* | Search query (search) | +| `tags` | Array | No | Article tags | + +--- + +## DevOps Nodes (13) + +### CI/CD Nodes + +| Node | Key Config | +|------|-----------| +| CI Trigger | `provider`, `repository`, `workflow_id`, `ref` | +| CI Wait Status | `run_id`, `timeout_seconds` | +| CI Get Logs | `run_id` | +| CI Cancel | `run_id` | + +### Git Nodes + +| Node | Key Config | +|------|-----------| +| Git Branch | `repository`, `branch`, `base_branch` | +| Git Checkout | `repository`, `ref` | +| Git Commit Status | `repository`, `commit_sha`, `status` | +| Git Create Check | `repository`, `commit_sha`, `check_name` | +| Git Comment | `repository`, `pr_number`/`issue_number`, `comment` | +| Git Pull Request | `repository`, `title`, `source_branch`, `target_branch` | + +### Deployment & Testing Nodes + +| Node | Key Config | +|------|-----------| +| Deploy | `environment`, `service`, `version`, `strategy` (rolling/blue_green/canary) | +| Run Tests | `test_suite`, `filter`, `parallel` | +| Shell Command | `command`, `working_directory`, `environment`, `timeout_seconds` | + +--- + +## MCP Nodes (4) + +| Node | Key Config | +|------|-----------| +| MCP Tool | `server_id`, `tool_name`, `arguments` | +| MCP Prompt | `server_id`, `prompt_name`, `arguments` | +| MCP Resource | `server_id`, `resource_uri` | +| Integration Execute | `integration_id`, `action`, `parameters` | + +--- + +## Utility Nodes (3) + +### Transform Node (`transform.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `transform_type` | String | No | map, filter, reduce, template | +| `input_variable` | String | No | Source variable | +| `mapping` | Hash | No | Field mapping (for map) | +| `filter_conditions` | Hash | No | Filter conditions | +| `reducer_function` | String | No | sum, count, first, last | +| `template` | String | No | Template string | + +### Human Approval Node (`human_approval.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `approvers` | Array | Yes | User IDs or role references | +| `approval_type` | String | No | any, all, majority, quorum | +| `timeout` | Integer | No | Seconds (default: 86400) | +| `timeout_action` | String | No | reject, approve, escalate, skip | +| `escalation_chain` | Array | No | Escalation user IDs | + +### Validator Node (`validator.rb`) + +| Config | Type | Required | Description | +|--------|------|----------|-------------| +| `schema` | Hash | Yes | JSON Schema for validation | +| `data_path` | String | No | Path to data to validate | +| `strict_mode` | Boolean | No | Fail on extra properties | + +--- + +## Error Handling + +```ruby +# Fatal errors — raise to fail the node +raise Mcp::AiWorkflowOrchestrator::NodeExecutionError, + "#{node.node_type} execution failed: #{e.message}" + +# Non-fatal errors — return error structure +{ + output: nil, + result: { success: false, error_message: "Description" }, + metadata: { node_id: @node.node_id, node_type: "type", error: true } +} +``` + +--- + +**Document Status**: Complete +**Source**: `server/app/services/mcp/node_executors/` diff --git a/docs/backend/PAYMENT_INTEGRATION_SPECIALIST.md b/docs/backend/PAYMENT_INTEGRATION_SPECIALIST.md new file mode 100644 index 000000000..59b9b6ea3 --- /dev/null +++ b/docs/backend/PAYMENT_INTEGRATION_SPECIALIST.md @@ -0,0 +1,970 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# Payment Integration Specialist Guide + +## Role & Responsibilities + +The Payment Integration Specialist handles all payment gateway integrations, webhook processing, and payment security for Powernode's subscription platform. + +### Core Responsibilities +- Integrating payment gateways (Stripe, PayPal) +- Handling webhook events and processing +- Implementing payment retry logic +- Managing payment method storage +- Handling refunds and chargebacks + +### Key Focus Areas +- PCI DSS compliance and security best practices +- Robust webhook handling and validation +- Payment method tokenization and storage +- Retry mechanisms and failure handling +- Comprehensive payment audit trails + +## Payment Gateway Integration Standards + +### 1. Stripe Integration (MANDATORY) + +#### Stripe Configuration +```ruby +# config/initializers/stripe.rb +Rails.configuration.stripe = { + publishable_key: ENV['STRIPE_PUBLISHABLE_KEY'], + secret_key: ENV['STRIPE_SECRET_KEY'], + webhook_secret: ENV['STRIPE_WEBHOOK_SECRET'] +} + +Stripe.api_key = Rails.configuration.stripe[:secret_key] +Stripe.api_version = '2023-10-16' + +# Enable request logging in development +if Rails.env.development? + Stripe.log_level = Stripe::LEVEL_DEBUG +end +``` + +#### Stripe Service Implementation +```ruby +# app/services/stripe_service.rb +class StripeService + class StripeServiceError < StandardError; end + + def initialize + @stripe_key = Rails.configuration.stripe[:secret_key] + validate_configuration! + end + + # Create customer in Stripe + def create_customer(account) + customer = Stripe::Customer.create({ + email: account.primary_email, + name: account.name, + metadata: { + account_id: account.id, + environment: Rails.env + } + }) + + account.update!(stripe_customer_id: customer.id) + customer + rescue Stripe::StripeError => e + Rails.logger.error "Stripe customer creation failed: #{e.message}" + raise StripeServiceError, "Failed to create customer: #{e.message}" + end + + # Create subscription + def create_subscription(account, plan, payment_method_token) + customer = ensure_customer(account) + + # Attach payment method to customer + payment_method = Stripe::PaymentMethod.attach( + payment_method_token, + { customer: customer.id } + ) + + # Create subscription + subscription = Stripe::Subscription.create({ + customer: customer.id, + items: [{ price: plan.stripe_price_id }], + default_payment_method: payment_method.id, + expand: ['latest_invoice.payment_intent'], + metadata: { + account_id: account.id, + plan_id: plan.id + } + }) + + # Store payment method locally + store_payment_method(account, payment_method) + + subscription + rescue Stripe::StripeError => e + Rails.logger.error "Stripe subscription creation failed: #{e.message}" + raise StripeServiceError, "Failed to create subscription: #{e.message}" + end + + # Process payment + def process_payment(subscription, amount_cents) + payment_intent = Stripe::PaymentIntent.create({ + amount: amount_cents, + currency: 'usd', + customer: subscription.account.stripe_customer_id, + payment_method: subscription.default_payment_method&.stripe_payment_method_id, + confirmation_method: 'manual', + confirm: true, + metadata: { + subscription_id: subscription.id, + account_id: subscription.account_id + } + }) + + # Create local payment record + create_local_payment(subscription, payment_intent, amount_cents) + + payment_intent + rescue Stripe::StripeError => e + Rails.logger.error "Stripe payment processing failed: #{e.message}" + raise StripeServiceError, "Payment processing failed: #{e.message}" + end + + # Handle subscription updates + def update_subscription(subscription, new_plan) + stripe_subscription = Stripe::Subscription.retrieve(subscription.stripe_subscription_id) + + Stripe::Subscription.modify(stripe_subscription.id, { + items: [{ + id: stripe_subscription.items.data[0].id, + price: new_plan.stripe_price_id + }], + proration_behavior: 'create_prorations', + metadata: { + plan_id: new_plan.id, + updated_at: Time.current.iso8601 + } + }) + rescue Stripe::StripeError => e + Rails.logger.error "Stripe subscription update failed: #{e.message}" + raise StripeServiceError, "Failed to update subscription: #{e.message}" + end + + private + + def validate_configuration! + required_keys = [:secret_key, :publishable_key, :webhook_secret] + missing_keys = required_keys.reject { |key| Rails.configuration.stripe[key].present? } + + if missing_keys.any? + raise StripeServiceError, "Missing Stripe configuration: #{missing_keys.join(', ')}" + end + end + + def ensure_customer(account) + return Stripe::Customer.retrieve(account.stripe_customer_id) if account.stripe_customer_id + + create_customer(account) + end + + def store_payment_method(account, stripe_pm) + PaymentMethod.create!( + account: account, + provider: 'stripe', + stripe_payment_method_id: stripe_pm.id, + method_type: stripe_pm.type, + last_four: stripe_pm.card&.last4, + exp_month: stripe_pm.card&.exp_month, + exp_year: stripe_pm.card&.exp_year, + brand: stripe_pm.card&.brand, + active: true + ) + end + + def create_local_payment(subscription, payment_intent, amount_cents) + Payment.create!( + subscription: subscription, + stripe_payment_intent_id: payment_intent.id, + amount_cents: amount_cents, + currency: payment_intent.currency, + status: payment_intent.status, + payment_method: subscription.default_payment_method, + metadata: payment_intent.metadata.to_h + ) + end +end +``` + +### 2. PayPal Integration (MANDATORY) + +#### PayPal Configuration +```ruby +# config/initializers/paypal.rb +PayPal::SDK.configure( + mode: Rails.env.production? ? 'live' : 'sandbox', + client_id: ENV['PAYPAL_CLIENT_ID'], + client_secret: ENV['PAYPAL_CLIENT_SECRET'], + ssl_options: { + ca_file: nil, + verify_mode: OpenSSL::SSL::VERIFY_PEER + } +) +``` + +#### PayPal Service Implementation +```ruby +# app/services/paypal_service.rb +class PaypalService + include PayPal::SDK::REST + + class PaypalServiceError < StandardError; end + + def initialize + validate_configuration! + end + + # Create billing plan + def create_billing_plan(plan) + billing_plan = BillingPlan.new({ + name: plan.name, + description: plan.description, + type: 'INFINITE', + payment_definitions: [{ + name: "#{plan.name} Payment", + type: 'REGULAR', + frequency: plan.billing_interval.upcase, + frequency_interval: '1', + amount: { + currency: 'USD', + value: plan.price.to_f.to_s + }, + cycles: '0' + }], + merchant_preferences: { + setup_fee: { + currency: 'USD', + value: '0' + }, + return_url: "#{ENV['FRONTEND_URL']}/billing/success", + cancel_url: "#{ENV['FRONTEND_URL']}/billing/cancel", + auto_bill_amount: 'YES', + initial_fail_amount_action: 'CONTINUE' + } + }) + + if billing_plan.create + # Activate the plan + billing_plan.activate + + # Store PayPal plan ID + plan.update!(paypal_plan_id: billing_plan.id) + billing_plan + else + raise PaypalServiceError, "Failed to create billing plan: #{billing_plan.error.inspect}" + end + end + + # Create billing agreement + def create_billing_agreement(account, plan) + billing_agreement = BillingAgreement.new({ + name: "#{plan.name} Subscription for #{account.name}", + description: "Subscription to #{plan.name}", + start_date: 1.minute.from_now.iso8601, + plan: { + id: plan.paypal_plan_id + }, + payer: { + payment_method: 'paypal' + } + }) + + if billing_agreement.create + billing_agreement + else + raise PaypalServiceError, "Failed to create billing agreement: #{billing_agreement.error.inspect}" + end + end + + # Execute billing agreement after user approval + def execute_billing_agreement(token, account, plan) + billing_agreement = BillingAgreement.new({ token: token }) + + if billing_agreement.execute + # Create local subscription record + subscription = account.subscriptions.create!( + plan: plan, + status: 'active', + paypal_agreement_id: billing_agreement.id, + current_period_start: Time.current, + current_period_end: 1.month.from_now + ) + + billing_agreement + else + raise PaypalServiceError, "Failed to execute billing agreement: #{billing_agreement.error.inspect}" + end + end + + # Cancel billing agreement + def cancel_billing_agreement(subscription) + billing_agreement = BillingAgreement.find(subscription.paypal_agreement_id) + + cancel_note = { + cancel_note: "Subscription cancelled by user" + } + + if billing_agreement.cancel(cancel_note) + subscription.update!(status: 'cancelled', cancelled_at: Time.current) + true + else + raise PaypalServiceError, "Failed to cancel billing agreement: #{billing_agreement.error.inspect}" + end + end + + private + + def validate_configuration! + required_env_vars = %w[PAYPAL_CLIENT_ID PAYPAL_CLIENT_SECRET] + missing_vars = required_env_vars.reject { |var| ENV[var].present? } + + if missing_vars.any? + raise PaypalServiceError, "Missing PayPal configuration: #{missing_vars.join(', ')}" + end + end +end +``` + +### 3. Webhook Processing (MANDATORY) + +#### Stripe Webhook Handler +```ruby +# app/controllers/webhooks/stripe_controller.rb +class Webhooks::StripeController < ApplicationController + skip_before_action :authenticate_request + before_action :verify_webhook_signature + + def handle + case @event.type + when 'payment_intent.succeeded' + handle_payment_succeeded + when 'payment_intent.payment_failed' + handle_payment_failed + when 'invoice.payment_succeeded' + handle_invoice_payment_succeeded + when 'invoice.payment_failed' + handle_invoice_payment_failed + when 'customer.subscription.updated' + handle_subscription_updated + when 'customer.subscription.deleted' + handle_subscription_cancelled + else + Rails.logger.info "Unhandled Stripe webhook event: #{@event.type}" + end + + render json: { received: true }, status: :ok + end + + private + + def verify_webhook_signature + payload = request.raw_post + sig_header = request.headers['Stripe-Signature'] + + begin + @event = Stripe::Webhook.construct_event( + payload, sig_header, Rails.configuration.stripe[:webhook_secret] + ) + rescue JSON::ParserError => e + Rails.logger.error "Stripe webhook JSON parsing error: #{e.message}" + render json: { error: 'Invalid payload' }, status: :bad_request + return + rescue Stripe::SignatureVerificationError => e + Rails.logger.error "Stripe webhook signature verification failed: #{e.message}" + render json: { error: 'Invalid signature' }, status: :bad_request + return + end + + # Log webhook for audit trail + AuditLog.create!( + action: 'webhook_received', + resource_type: 'Stripe', + resource_id: @event.id, + details: { + event_type: @event.type, + created: @event.created, + livemode: @event.livemode + }, + ip_address: request.remote_ip, + metadata: { user_agent: request.user_agent } + ) + end + + def handle_payment_succeeded + payment_intent = @event.data.object + + payment = Payment.find_by(stripe_payment_intent_id: payment_intent.id) + if payment + payment.update!( + status: 'succeeded', + processed_at: Time.current, + metadata: payment_intent.metadata.to_h + ) + + # Delegate to worker for post-processing + WorkerJobService.enqueue_billing_job('payment_succeeded', { + payment_id: payment.id, + payment_intent_id: payment_intent.id + }) + end + end + + def handle_payment_failed + payment_intent = @event.data.object + + payment = Payment.find_by(stripe_payment_intent_id: payment_intent.id) + if payment + payment.update!( + status: 'failed', + failure_reason: payment_intent.last_payment_error&.message, + processed_at: Time.current + ) + + # Delegate to worker for retry logic + WorkerJobService.enqueue_billing_job('payment_failed', { + payment_id: payment.id, + subscription_id: payment.subscription_id, + failure_reason: payment_intent.last_payment_error&.message + }) + end + end + + def handle_subscription_updated + stripe_subscription = @event.data.object + + subscription = Subscription.find_by(stripe_subscription_id: stripe_subscription.id) + if subscription + subscription.update!( + status: stripe_subscription.status, + current_period_start: Time.at(stripe_subscription.current_period_start), + current_period_end: Time.at(stripe_subscription.current_period_end), + metadata: stripe_subscription.metadata.to_h + ) + + # Broadcast update to frontend + SubscriptionBroadcastService.broadcast_update(subscription) + end + end +end +``` + +#### PayPal Webhook Handler +```ruby +# app/controllers/webhooks/paypal_controller.rb +class Webhooks::PaypalController < ApplicationController + skip_before_action :authenticate_request + before_action :verify_webhook_signature + + def handle + case @event_type + when 'BILLING.SUBSCRIPTION.ACTIVATED' + handle_subscription_activated + when 'BILLING.SUBSCRIPTION.CANCELLED' + handle_subscription_cancelled + when 'PAYMENT.SALE.COMPLETED' + handle_payment_completed + when 'PAYMENT.SALE.DENIED' + handle_payment_denied + else + Rails.logger.info "Unhandled PayPal webhook event: #{@event_type}" + end + + render json: { received: true }, status: :ok + end + + private + + def verify_webhook_signature + @webhook_data = JSON.parse(request.raw_post) + @event_type = @webhook_data['event_type'] + + # PayPal webhook signature verification + verifier = PaypalWebhookVerifier.new + unless verifier.verify(request.headers, request.raw_post) + Rails.logger.error "PayPal webhook signature verification failed" + render json: { error: 'Invalid signature' }, status: :bad_request + return + end + + # Log webhook for audit trail + AuditLog.create!( + action: 'webhook_received', + resource_type: 'PayPal', + resource_id: @webhook_data['id'], + details: { + event_type: @event_type, + create_time: @webhook_data['create_time'], + summary: @webhook_data['summary'] + }, + ip_address: request.remote_ip + ) + end + + def handle_subscription_activated + agreement_id = @webhook_data.dig('resource', 'id') + + subscription = Subscription.find_by(paypal_agreement_id: agreement_id) + if subscription + subscription.update!( + status: 'active', + activated_at: Time.current + ) + + # Broadcast update + SubscriptionBroadcastService.broadcast_update(subscription) + end + end + + def handle_payment_completed + sale_id = @webhook_data.dig('resource', 'id') + agreement_id = @webhook_data.dig('resource', 'billing_agreement_id') + + subscription = Subscription.find_by(paypal_agreement_id: agreement_id) + if subscription + # Create payment record + Payment.create!( + subscription: subscription, + paypal_sale_id: sale_id, + amount_cents: (@webhook_data.dig('resource', 'amount', 'total').to_f * 100).to_i, + currency: @webhook_data.dig('resource', 'amount', 'currency'), + status: 'succeeded', + processed_at: Time.current, + metadata: @webhook_data['resource'] + ) + + # Delegate to worker + WorkerJobService.enqueue_billing_job('paypal_payment_completed', { + subscription_id: subscription.id, + sale_id: sale_id + }) + end + end +end +``` + +### 4. Payment Method Security (MANDATORY) + +#### Payment Method Model +```ruby +# app/models/payment_method.rb +class PaymentMethod < ApplicationRecord + belongs_to :account + has_many :payments, dependent: :destroy + + validates :provider, inclusion: { in: %w[stripe paypal] } + validates :method_type, inclusion: { in: %w[card bank_account paypal] } + validates :last_four, presence: true, length: { is: 4 } + + scope :active, -> { where(active: true) } + scope :cards, -> { where(method_type: 'card') } + + # Never store full payment details + def display_name + case method_type + when 'card' + "#{brand&.capitalize} ending in #{last_four}" + when 'paypal' + "PayPal (#{email})" + when 'bank_account' + "Bank ending in #{last_four}" + else + "Payment method ending in #{last_four}" + end + end + + def expired? + return false unless exp_month && exp_year + Date.new(exp_year, exp_month, -1) < Date.current + end +end +``` + +#### PCI Compliance Validation +```ruby +# app/services/payment_method_security_validator.rb +class PaymentMethodSecurityValidator + PCI_VIOLATION_PATTERNS = [ + /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, # Full credit card numbers + /\b\d{3,4}\b.*\b\d{2}\/\d{2,4}\b/, # CVV with expiration + /pan|primary.account.number/i, # PAN references + /cvv|cvc|security.code/i # Security codes + ].freeze + + def self.validate_data(data) + violations = [] + + data_string = data.to_json.downcase + + PCI_VIOLATION_PATTERNS.each do |pattern| + if data_string.match?(pattern) + violations << "Potential PCI violation: #{pattern.source}" + end + end + + violations + end + + def self.sanitize_payment_data(params) + # Remove sensitive fields that should never be stored + sensitive_fields = %w[ + card_number cvv cvc security_code pan + full_name billing_address + ] + + params.deep_dup.tap do |sanitized| + sensitive_fields.each do |field| + sanitized.delete(field) + sanitized.delete(field.to_sym) + end + end + end +end +``` + +### 5. Payment Retry Logic (MANDATORY) + +#### Retry Service Implementation +```ruby +# app/services/payment_retry_service.rb +class PaymentRetryService < BaseService + attribute :payment, Payment + + RETRY_SCHEDULE = [1.day, 3.days, 5.days, 7.days].freeze + MAX_RETRIES = RETRY_SCHEDULE.length + + def call + return failure("Payment not found") unless payment + return failure("Payment already succeeded") if payment.succeeded? + return failure("Maximum retries exceeded") if max_retries_exceeded? + + begin + result = process_retry + + if result.success? + success({ payment: payment_data(payment) }) + else + schedule_next_retry + failure("Retry failed", result.details) + end + rescue StandardError => e + Rails.logger.error "Payment retry failed: #{e.message}" + failure("Retry processing error", { error: e.message }) + end + end + + private + + def process_retry + case payment.provider + when 'stripe' + StripeService.new.retry_payment(payment) + when 'paypal' + PaypalService.new.retry_payment(payment) + else + ServiceResult.new(success: false, error: "Unknown payment provider") + end + end + + def max_retries_exceeded? + payment.retry_count >= MAX_RETRIES + end + + def schedule_next_retry + next_retry_delay = RETRY_SCHEDULE[payment.retry_count] + + if next_retry_delay + # Delegate to worker service + WorkerJobService.enqueue_billing_job('payment_retry', { + payment_id: payment.id, + retry_attempt: payment.retry_count + 1, + scheduled_at: next_retry_delay.from_now.iso8601 + }) + + payment.update!( + retry_count: payment.retry_count + 1, + next_retry_at: next_retry_delay.from_now + ) + else + # Mark as permanently failed + payment.update!( + status: 'permanently_failed', + retry_count: payment.retry_count + 1 + ) + + # Notify customer + WorkerJobService.enqueue_billing_job('payment_permanently_failed', { + subscription_id: payment.subscription_id, + payment_id: payment.id + }) + end + end +end +``` + +### 6. Refund and Chargeback Handling (MANDATORY) + +#### Refund Service +```ruby +# app/services/refund_service.rb +class RefundService < BaseService + attribute :payment, Payment + attribute :amount_cents, Integer + attribute :reason, String + + validates :payment, :reason, presence: true + + def call + return failure("Invalid parameters", errors.full_messages) unless valid? + return failure("Payment not refundable") unless payment.refundable? + + begin + case payment.provider + when 'stripe' + process_stripe_refund + when 'paypal' + process_paypal_refund + else + failure("Unsupported payment provider") + end + rescue StandardError => e + Rails.logger.error "Refund processing failed: #{e.message}" + failure("Refund failed", { error: e.message }) + end + end + + private + + def process_stripe_refund + refund_amount = amount_cents || payment.amount_cents + + stripe_refund = Stripe::Refund.create({ + payment_intent: payment.stripe_payment_intent_id, + amount: refund_amount, + reason: map_refund_reason(reason), + metadata: { + payment_id: payment.id, + refund_reason: reason + } + }) + + # Create local refund record + create_refund_record(stripe_refund, refund_amount) + + success({ refund_id: stripe_refund.id, amount_cents: refund_amount }) + end + + def process_paypal_refund + # PayPal refund implementation + refund_amount = amount_cents || payment.amount_cents + + sale = Sale.find(payment.paypal_sale_id) + refund_request = RefundRequest.new({ + amount: { + total: (refund_amount / 100.0).to_s, + currency: payment.currency.upcase + }, + reason: reason + }) + + refund = sale.refund(refund_request) + + if refund.success? + create_refund_record(refund, refund_amount) + success({ refund_id: refund.id, amount_cents: refund_amount }) + else + failure("PayPal refund failed", { error: refund.error }) + end + end + + def create_refund_record(gateway_refund, amount_cents) + Refund.create!( + payment: payment, + amount_cents: amount_cents, + reason: reason, + provider_refund_id: gateway_refund.id, + status: 'processed', + processed_at: Time.current, + metadata: gateway_refund.to_h + ) + + # Update payment status + payment.update!(refunded_amount_cents: payment.refunded_amount_cents + amount_cents) + end + + def map_refund_reason(reason) + case reason.downcase + when /duplicate/ + 'duplicate' + when /fraud/ + 'fraudulent' + when /customer/ + 'requested_by_customer' + else + 'requested_by_customer' + end + end +end +``` + +### 7. Security and Compliance (MANDATORY) + +#### PCI DSS Compliance Headers +```ruby +# app/middleware/pci_security_headers.rb +class PciSecurityHeaders + def initialize(app) + @app = app + end + + def call(env) + status, headers, response = @app.call(env) + + # PCI DSS required headers + headers['X-Frame-Options'] = 'DENY' + headers['X-Content-Type-Options'] = 'nosniff' + headers['X-XSS-Protection'] = '1; mode=block' + headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload' + headers['Content-Security-Policy'] = build_csp_header + headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + + # Remove server information + headers.delete('Server') + headers.delete('X-Powered-By') + + [status, headers, response] + end + + private + + def build_csp_header + [ + "default-src 'self'", + "script-src 'self' js.stripe.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "connect-src 'self' api.stripe.com", + "frame-src js.stripe.com hooks.stripe.com", + "object-src 'none'", + "base-uri 'none'" + ].join('; ') + end +end +``` + +#### Sensitive Data Sanitizer +```ruby +# app/services/sensitive_data_sanitizer.rb +class SensitiveDataSanitizer + SENSITIVE_PATTERNS = { + credit_card: /\b(?:\d[ -]*?){13,19}\b/, + ssn: /\b\d{3}-?\d{2}-?\d{4}\b/, + email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, + phone: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/ + }.freeze + + def self.sanitize(text) + return text unless text.is_a?(String) + + sanitized = text.dup + + SENSITIVE_PATTERNS.each do |type, pattern| + sanitized.gsub!(pattern, "[#{type.to_s.upcase}_REDACTED]") + end + + sanitized + end + + def self.sanitize_hash(hash) + hash.deep_transform_values do |value| + value.is_a?(String) ? sanitize(value) : value + end + end + + def self.log_safe(data) + case data + when Hash + sanitize_hash(data) + when String + sanitize(data) + else + data + end + end +end +``` + +## Development Commands + +### Payment Gateway Setup +```bash +# Install payment gems +bundle add stripe paypal-sdk-rest + +# Generate payment controllers and models +rails generate controller Webhooks::Stripe +rails generate controller Webhooks::Paypal +rails generate model PaymentMethod account:references provider:string +rails generate model Payment subscription:references amount_cents:integer +rails generate model Refund payment:references amount_cents:integer + +# Run payment-related migrations +rails db:migrate + +# Test webhook endpoints +curl -X POST localhost:3000/webhooks/stripe \ + -H "Content-Type: application/json" \ + -d '{"test": "data"}' +``` + +### Security Testing +```bash +# Test PCI compliance +curl -I localhost:3000/api/v1/subscriptions + +# Validate webhook signatures +rails runner "puts StripeWebhookVerifier.verify(headers, payload)" + +# Test payment processing in console +rails console +> StripeService.new.create_customer(Account.first) +> PaypalService.new.create_billing_plan(Plan.first) +``` + +## Integration Points + +### Payment Integration Specialist Coordinates With: +- **Billing Engine Developer**: Subscription lifecycle, payment processing +- **Backend Job Engineer**: Webhook processing, retry mechanisms +- **Security Specialist**: PCI compliance, data protection +- **API Developer**: Payment endpoint security, error handling +- **Notification Engineer**: Payment failure notifications, receipts + +## Quick Reference + +### Payment Processing Flow +1. **Customer Setup**: Create customer in payment gateway +2. **Payment Method**: Tokenize and store payment method securely +3. **Subscription Creation**: Create recurring billing setup +4. **Payment Processing**: Process individual payments +5. **Webhook Handling**: Process gateway notifications +6. **Retry Logic**: Handle failed payments automatically +7. **Refund Processing**: Handle refund requests +8. **Audit Logging**: Track all payment operations + +### Security Checklist +- ✅ Never store full payment card data +- ✅ Use tokenization for payment methods +- ✅ Validate webhook signatures +- ✅ Implement PCI DSS security headers +- ✅ Sanitize all logged data +- ✅ Use HTTPS for all payment communications +- ✅ Implement proper error handling without exposing sensitive data +- ✅ Regular security audits and penetration testing diff --git a/docs/backend/RAILS_ARCHITECT_SPECIALIST.md b/docs/backend/RAILS_ARCHITECT_SPECIALIST.md new file mode 100644 index 000000000..87d44f569 --- /dev/null +++ b/docs/backend/RAILS_ARCHITECT_SPECIALIST.md @@ -0,0 +1,1256 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# Rails Architect Specialist Guide + +## Platform Statistics + +| Metric | Count | +|--------|-------| +| Ruby | 3.2.8 | +| Rails | 8.1.2 | +| Models | 261 | +| Controllers | 244 | +| Services | 560 | +| Database Tables | 352 | +| ActionCable Channels | 17 | +| Model Namespaces | 13 | +| Controller Namespaces | 16 | +| Service Namespaces | 23 | +| Schema Lines | ~10,500 | + +### Key Gems + +| Gem | Purpose | +|-----|---------| +| `rails` 8.1.2 | Web framework | +| `uuid7` | UUIDv7 primary keys | +| `neighbor` | pgvector integration (HNSW indexes) | +| `doorkeeper` | OAuth2 provider | +| `flipper` | Feature flags | +| `sentry-ruby` | Error tracking | +| `solid_cache` / `solid_queue` / `solid_cable` | Rails 8 solid adapters | + +### Model Namespaces + +| Namespace | Description | +|-----------|-------------| +| `Account` | Account-related models | +| `Ai` | AI agents, conversations, workflows, teams, memory, knowledge graph (135+ models) | +| `BaaS` | Backend-as-a-Service models | +| `Chat` | Multi-platform chat (channels, sessions, messages) | +| `Database` | Database management models | +| `DataManagement` | Data import/export/transformation | +| `Devops` | CI/CD, containers, Docker, Swarm, git (41 models) | +| `FileManagement` | File storage and management | +| `KnowledgeBase` | KB articles, categories, tags, workflows | +| `Mcp` | MCP server and tool models | +| `Monitoring` | System monitoring and health | +| `Shared` | Cross-cutting shared models | +| Root (no namespace) | Core models (User, Account, Plan, Subscription, etc.) | + +## Related References + +For common patterns used across multiple specialists, see these consolidated references: +- **[API Response Standards](../platform/API_RESPONSE_STANDARDS.md)** - Unified response format documentation +- **[Permission System Reference](../platform/PERMISSION_SYSTEM_REFERENCE.md)** - Backend permission patterns +- **[DevOps Platform Guide](../platform/DEVOPS_PLATFORM_GUIDE.md)** - CI/CD and container orchestration +- **[Chat System Architecture](../platform/CHAT_SYSTEM_ARCHITECTURE.md)** - Real-time messaging +- **[Content Management Guide](../platform/CONTENT_MANAGEMENT_GUIDE.md)** - KB articles and pages + +## Role & Responsibilities + +The Rails Architect specializes in Rails 8 API setup, configuration, and architectural decisions for Powernode's subscription platform. + +### Core Responsibilities +- Setting up Rails 8.1 API-only applications +- Configuring database connections and migrations (352 tables, UUIDv7 PKs) +- Designing RESTful API endpoints (244 controllers across 15+ namespaces) +- Setting up middleware and security configurations +- Implementing authentication systems (JWT with impersonation) +- Managing 17 ActionCable channels for real-time communication + +### Key Focus Areas +- Rails 8.1 conventions and best practices +- API-only architecture patterns with 13 model namespaces +- Security configuration and middleware (PCI compliance) +- Database configuration and optimization (PostgreSQL + pgvector) +- Authentication and authorization systems (permission-based, never role-based) +- WebSocket communication via ActionCable + +## Rails 8 API Architecture Standards + +### 1. Standard Controller Pattern (MANDATORY) + +**Pattern**: Consistent API Controller Architecture +```ruby +# Standard controller structure following platform conventions +class Api::V1::[Resource]Controller < ApplicationController + # Include relevant concerns for functionality + include [Resource]Serialization + + # Set resource for actions that need it + before_action :set_resource, only: [:show, :update, :destroy] + + # Permission-based authorization (NOT role-based) + before_action -> { require_permission('[resource].view') }, only: [:index, :show] + before_action -> { require_permission('[resource].create') }, only: [:create] + before_action -> { require_permission('[resource].update') }, only: [:update] + before_action -> { require_permission('[resource].delete') }, only: [:destroy] + + # Standard CRUD operations + def index + resources = current_account.[resources].includes(:associated_models) + + # Use ApiResponse concern method + render_success(resources.map { |resource| [resource]_data(resource) }) + end + + def show + # Use ApiResponse concern method + render_success([resource]_data(@[resource])) + end + + def create + @[resource] = current_account.[resources].build([resource]_params) + + if @[resource].save + # Use ApiResponse concern method for 201 Created response + render_created([resource]_data(@[resource])) + else + # Use ApiResponse concern method for validation errors + render_validation_error(@[resource].errors) + end + end + + private + + def set_[resource] + @[resource] = current_account.[resources].find(params[:id]) + end + + def [resource]_params + params.require(:[resource]).permit(:attribute1, :attribute2) + end +end +``` + +**Key Standards**: +- **Namespace**: All API controllers in `Api::V1` module +- **Inheritance**: Inherit from `ApplicationController` +- **Concerns**: Include serialization and other modular functionality +- **Permissions**: Use `require_permission()` with lambda syntax +- **Response Format**: Consistent `{success, data, error, message}` structure +- **Error Handling**: Structured error responses with details +- **Resource Scoping**: Always scope to `current_account` + +### 2. Authentication & Authorization Pattern (CRITICAL) + +**Pattern**: Permission-Based Access Control System +```ruby +# app/controllers/concerns/authentication.rb +module Authentication + extend ActiveSupport::Concern + + included do + before_action :authenticate_request + attr_reader :current_user, :current_account + end + + private + + def authenticate_request + header = request.headers["Authorization"] + header = header.split(" ").last if header + + return render_unauthorized("Access token required") unless header + + begin + payload = JwtService.decode(header) + + # Handle different token types + if payload[:type] == 'impersonation' + handle_impersonation_token(payload) + else + handle_regular_token(payload) + end + + return render_unauthorized("User inactive") unless @current_user.active? + return render_unauthorized("Account suspended") unless @current_account.active? + + rescue JWT::DecodeError => e + Rails.logger.warn "JWT decode error: #{e.message}" + render_unauthorized("Invalid access token") + rescue JWT::ExpiredSignature + render_unauthorized("Access token expired") + end + end + + def require_permission(permission) + return render_unauthorized("Permission required") unless current_user + + unless current_user.has_permission?(permission) + Rails.logger.warn "Permission denied: User #{current_user.id} lacks '#{permission}'" + render_forbidden("Insufficient permissions") + end + end + + def render_unauthorized(message = "Unauthorized") + render json: { + success: false, + error: message, + code: "UNAUTHORIZED" + }, status: :unauthorized + end + + def render_forbidden(message = "Forbidden") + render json: { + success: false, + error: message, + code: "FORBIDDEN" + }, status: :forbidden + end + + private + + def handle_regular_token(payload) + @current_user = User.find(payload[:user_id]) + @current_account = @current_user.account + rescue ActiveRecord::RecordNotFound + render_unauthorized("User not found") + end + + def handle_impersonation_token(payload) + impersonator = User.find(payload[:impersonator_id]) + impersonated = User.find(payload[:impersonated_user_id]) + + # Verify impersonation session is still valid + session = ImpersonationSession.active + .find_by( + impersonator: impersonator, + impersonated_user: impersonated + ) + + return render_unauthorized("Impersonation session invalid") unless session + + @current_user = impersonated + @current_account = impersonated.account + @impersonator = impersonator + rescue ActiveRecord::RecordNotFound + render_unauthorized("Impersonation users not found") + end +end +``` + +**Authentication Features**: +- **JWT Token Validation**: Decode and validate access tokens +- **Impersonation Support**: Handle admin impersonation tokens +- **Permission Checking**: `require_permission()` method for granular access +- **User/Account Context**: Set `current_user` and `current_account` +- **Error Handling**: Consistent unauthorized/forbidden responses + +**Permission System Standards**: +- **Format**: `resource.action` (e.g., `users.view`, `billing.manage`) +- **Frontend Rule**: NEVER use roles for access control, only permissions +- **Backend Rule**: Roles assign permissions, controllers check permissions +- **Granularity**: Specific permissions for each action + +### 3. Application Configuration (MANDATORY) + +#### Rails API-Only Setup +```ruby +# config/application.rb +module Powernode + class Application < Rails::Application + config.load_defaults 8.0 + config.api_only = true + + # CORS configuration + config.middleware.insert_before 0, Rack::Cors do + allow do + origins '*' + resource '*', + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head], + credentials: false + end + end + + # Rate limiting + config.middleware.use Rack::Attack + + # Custom middleware + config.middleware.use PciSecurityHeaders + config.middleware.use AuditLoggingMiddleware + end +end +``` + +#### Environment Configuration +```ruby +# config/environments/development.rb +Rails.application.configure do + config.cache_classes = false + config.eager_load = false + config.consider_all_requests_local = true + config.server_timing = true + + # Action Cable configuration + config.action_cable.url = "ws://localhost:3000/cable" + config.action_cable.allowed_request_origins = [/http:\/\/*/, /https:\/\/*/] + + # Logging configuration + config.log_level = :debug + config.log_tags = [:request_id, :remote_ip] +end + +# config/environments/production.rb +Rails.application.configure do + config.cache_classes = true + config.eager_load = true + config.consider_all_requests_local = false + + # Force SSL and security headers + config.force_ssl = true + config.ssl_options = { redirect: { exclude: ->(request) { request.path =~ /health/ } } } + + # Action Cable for production + config.action_cable.url = "wss://api.powernode.com/cable" + config.action_cable.allowed_request_origins = ["https://app.powernode.com"] + + # Logging + config.log_level = :info + config.log_tags = [:request_id, :remote_ip, :subdomain] +end +``` + +### 4. Standardized Error Handling Pattern (MANDATORY) + +**Pattern**: ApplicationController Error Handling +```ruby +# app/controllers/application_controller.rb +class ApplicationController < ActionController::API + include Authentication + include ApiResponse # CRITICAL: Standard API response concern + + # ApiResponse concern handles all exception responses automatically + # No manual rescue_from needed - concern provides: + # - ActiveRecord::RecordNotFound → render_not_found + # - ActiveRecord::RecordInvalid → render_validation_error + # - StandardError → render_internal_error + + # Standard pagination parameters helper + def pagination_params + { + page: [ params[:page]&.to_i || 1, 1 ].max, + per_page: [ [ params[:per_page]&.to_i || 20, 1 ].max, 100 ].min + } + end +end +``` + +#### ApiResponse Concern Benefits +- **Consistent Response Format**: All endpoints use standardized JSON structure +- **Automatic Error Handling**: Built-in exception rescue and formatting +- **HTTP Status Codes**: Proper status codes for different response types +- **Pagination Support**: Built-in paginated response helper +- **Extensible**: Easy to add new response patterns + +### 5. API Response Examples + + def render_internal_error(exception) + Rails.logger.error "Internal error: #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") if Rails.env.development? + + render json: { + success: false, + error: "Internal server error", + message: Rails.env.development? ? exception.message : "Something went wrong", + code: "INTERNAL_ERROR" + }, status: :internal_server_error + end + + # Pagination helper + def pagination_params + { + page: [params[:page]&.to_i || 1, 1].max, + per_page: [[params[:per_page]&.to_i || 20, 1].max, 100].min + } + end +end +``` + +**Error Response Standards**: +- **Success Field**: Always include `success: false` for errors +- **Error Field**: Primary error message for user display +- **Details Field**: Array of detailed errors (for validation errors) +- **Code Field**: Machine-readable error code for frontend handling +- **Message Field**: Additional context or technical details +- **HTTP Status**: Appropriate semantic HTTP status codes + +### 5. Controller Concern Pattern (RECOMMENDED) + +**Pattern**: Reusable Controller Functionality +```ruby +# app/controllers/concerns/user_serialization.rb +module UserSerialization + extend ActiveSupport::Concern + + private + + def user_data(user, include_roles: false, include_permissions: true) + { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + status: user.status, + email_verified: user.email_verified?, + last_sign_in_at: user.last_sign_in_at, + permissions: include_permissions ? user.all_permissions : nil, + roles: include_roles ? user.roles.map(&:name) : nil, + created_at: user.created_at, + updated_at: user.updated_at + }.compact + end + + def users_data(users, **options) + users.map { |user| user_data(user, **options) } + end +end + +# Usage in controllers +class Api::V1::UsersController < ApplicationController + include UserSerialization + + def index + users = current_account.users.includes(:roles) + render json: { + success: true, + data: users_data(users, include_roles: true) + }, status: :ok + end +end +``` + +**Concern Benefits**: +- **DRY Principle**: Avoid code duplication across controllers +- **Consistency**: Standardized data serialization +- **Maintainability**: Centralized serialization logic +- **Testing**: Easier to test serialization logic + +### 6. Database Configuration (MANDATORY) + +#### Database Setup +```yaml +# config/database.yml +default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + username: <%= ENV['DATABASE_USERNAME'] %> + password: <%= ENV['DATABASE_PASSWORD'] %> + host: <%= ENV['DATABASE_HOST'] %> + port: <%= ENV['DATABASE_PORT'] %> + +development: + <<: *default + database: powernode_development + +test: + <<: *default + database: powernode_test + +production: + <<: *default + database: powernode_production + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 25 } %> +``` + +#### Database Initializers +```ruby +# config/initializers/uuid_primary_keys.rb +Rails.application.config.generators do |g| + g.orm :active_record, primary_key_type: :uuid +end + +# Enable PostgreSQL extensions +ActiveSupport.on_load(:active_record) do + connection.execute("CREATE EXTENSION IF NOT EXISTS 'uuid-ossp'") + connection.execute("CREATE EXTENSION IF NOT EXISTS 'pgcrypto'") +end +``` + +### 3. Controller Architecture (MANDATORY) + +#### Base Controller Pattern +```ruby +# app/controllers/application_controller.rb +class ApplicationController < ActionController::API + include Authentication + include RateLimiting + include AuditLogging + include UserSerialization + + before_action :authenticate_request + before_action :set_current_user + around_action :log_request + + rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity + rescue_from StandardError, with: :internal_server_error + + private + + def not_found(exception) + render json: { + success: false, + error: "Record not found", + details: exception.message + }, status: :not_found + end + + def unprocessable_entity(exception) + render json: { + success: false, + error: "Validation failed", + details: exception.record.errors.full_messages + }, status: :unprocessable_entity + end + + def internal_server_error(exception) + Rails.logger.error "Internal Server Error: #{exception.message}" + render json: { + success: false, + error: "Internal server error" + }, status: :internal_server_error + end +end +``` + +#### API Versioning Pattern +```ruby +# app/controllers/api/v1/base_controller.rb +class Api::V1::BaseController < ApplicationController + before_action :set_api_version + + private + + def set_api_version + response.headers['API-Version'] = 'v1' + end +end + +# Example API controller +class Api::V1::SubscriptionsController < Api::V1::BaseController + before_action :set_subscription, only: [:show, :update, :destroy] + + def index + subscriptions = current_user.account.subscriptions + .includes(:plan, :payments) + .page(params[:page]) + .per(params[:per_page] || 20) + + render json: { + success: true, + data: subscriptions.map { |s| subscription_data(s) }, + pagination: pagination_data(subscriptions) + } + end + + def show + render json: { + success: true, + data: subscription_data(@subscription) + } + end + + def create + subscription = current_user.account.subscriptions.build(subscription_params) + + if subscription.save + render json: { + success: true, + data: subscription_data(subscription), + message: "Subscription created successfully" + }, status: :created + else + render json: { + success: false, + error: "Failed to create subscription", + details: subscription.errors.full_messages + }, status: :unprocessable_content + end + end + + private + + def set_subscription + @subscription = current_user.account.subscriptions.find(params[:id]) + end + + def subscription_params + params.require(:subscription).permit(:plan_id, :status) + end + + def subscription_data(subscription) + { + id: subscription.id, + status: subscription.status, + plan: { + id: subscription.plan.id, + name: subscription.plan.name, + price: subscription.plan.price.format + }, + current_period_start: subscription.current_period_start&.iso8601, + current_period_end: subscription.current_period_end&.iso8601, + created_at: subscription.created_at.iso8601, + updated_at: subscription.updated_at.iso8601 + } + end +end +``` + +### 4. Authentication Architecture (MANDATORY) + +#### JWT Authentication Implementation +```ruby +# app/services/jwt_service.rb +class JwtService + SECRET_KEY = Rails.application.secrets.secret_key_base + + def self.encode(payload, exp = 15.minutes.from_now) + payload[:exp] = exp.to_i + JWT.encode(payload, SECRET_KEY, 'HS256') + end + + def self.decode(token) + decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' })[0] + HashWithIndifferentAccess.new(decoded) + rescue JWT::DecodeError, JWT::ExpiredSignature + nil + end +end + +# app/controllers/concerns/authentication.rb +module Authentication + extend ActiveSupport::Concern + + included do + attr_reader :current_user + end + + private + + def authenticate_request + header = request.headers['Authorization'] + header = header.split(' ').last if header + + begin + @decoded = JwtService.decode(header) + @current_user = User.find(@decoded[:user_id]) if @decoded + rescue ActiveRecord::RecordNotFound, JWT::DecodeError + @current_user = nil + end + + render_unauthorized unless @current_user + end + + def set_current_user + Current.user = @current_user if @current_user + end + + def render_unauthorized + render json: { + success: false, + error: 'Unauthorized access' + }, status: :unauthorized + end +end +``` + +#### Authentication Controllers +```ruby +# app/controllers/api/v1/auth_controller.rb +class Api::V1::AuthController < Api::V1::BaseController + skip_before_action :authenticate_request, only: [:login, :register, :forgot_password] + + def login + user = User.find_by(email: params[:email]) + + if user&.authenticate(params[:password]) + if user.email_verified? + token = JwtService.encode(user_id: user.id) + refresh_token = generate_refresh_token(user) + + render json: { + success: true, + data: { + token: token, + refresh_token: refresh_token, + user: user_data(user) + }, + message: "Login successful" + } + else + render json: { + success: false, + error: "Please verify your email before logging in" + }, status: :unauthorized + end + else + render json: { + success: false, + error: "Invalid email or password" + }, status: :unauthorized + end + end + + def register + user = User.new(registration_params) + user.account = Account.create!(name: "#{user.email} Account") + + if user.save + UserMailer.email_verification(user).deliver_now + render json: { + success: true, + message: "Registration successful. Please check your email to verify your account.", + data: { email: user.email } + }, status: :created + else + render json: { + success: false, + error: "Registration failed", + details: user.errors.full_messages + }, status: :unprocessable_content + end + end + + private + + def registration_params + params.require(:user).permit(:email, :password, :password_confirmation, :first_name, :last_name) + end + + def generate_refresh_token(user) + JwtService.encode({ user_id: user.id, type: 'refresh' }, 7.days.from_now) + end +end +``` + +### 5. Middleware Configuration (MANDATORY) + +#### Security Middleware +```ruby +# app/middleware/pci_security_headers.rb +class PciSecurityHeaders + def initialize(app) + @app = app + end + + def call(env) + status, headers, response = @app.call(env) + + # PCI DSS required security headers + headers['X-Frame-Options'] = 'DENY' + headers['X-Content-Type-Options'] = 'nosniff' + headers['X-XSS-Protection'] = '1; mode=block' + headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + headers['Content-Security-Policy'] = "default-src 'self'" + headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + + [status, headers, response] + end +end + +# app/middleware/audit_logging_middleware.rb +class AuditLoggingMiddleware + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new(env) + start_time = Time.current + + status, headers, response = @app.call(env) + + # Log API requests for audit trail + if request.path.start_with?('/api/') + AuditLoggingService.log_request( + path: request.path, + method: request.method, + ip_address: request.remote_ip, + user_agent: request.user_agent, + status: status, + duration: Time.current - start_time + ) + end + + [status, headers, response] + end +end +``` + +#### Rate Limiting Configuration +```ruby +# config/initializers/rack_attack.rb +class Rack::Attack + # Throttle all requests by IP (60 requests per minute) + throttle('req/ip', limit: 60, period: 1.minute) do |req| + req.ip + end + + # Throttle login attempts by IP (5 attempts per minute) + throttle('login/ip', limit: 5, period: 1.minute) do |req| + req.ip if req.path == '/api/v1/auth/login' && req.post? + end + + # Throttle API requests by user (1000 requests per hour) + throttle('api/user', limit: 1000, period: 1.hour) do |req| + if req.path.start_with?('/api/') && req.env['current_user'] + req.env['current_user'].id + end + end + + # Block known malicious IPs + blocklist('malicious-ips') do |req| + Rails.cache.read("blocked_ip:#{req.ip}") + end +end +``` + +### 6. WebSocket Configuration (MANDATORY) + +#### Action Cable Setup +```ruby +# app/channels/application_cable/connection.rb +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + private + + def find_verified_user + token = request.params[:token] + + if token && (decoded = JwtService.decode(token)) + User.find(decoded[:user_id]) + else + reject_unauthorized_connection + end + rescue ActiveRecord::RecordNotFound + reject_unauthorized_connection + end + end +end + +# app/channels/application_cable/channel.rb +module ApplicationCable + class Channel < ActionCable::Channel::Base + protected + + def current_account + current_user&.account + end + end +end +``` + +#### Subscription Channel Example +```ruby +# app/channels/subscription_channel.rb +class SubscriptionChannel < ApplicationCable::Channel + def subscribed + stream_from "subscription_#{current_account.id}" + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end +``` + +### 7. Routing Configuration (MANDATORY) + +#### API Routes Structure +```ruby +# config/routes.rb +Rails.application.routes.draw do + # Health check endpoints + get '/health', to: 'health#show' + get '/health/deep', to: 'health#deep' + + # WebSocket cable + mount ActionCable.server => '/cable' + + # API versioning + namespace :api do + namespace :v1 do + # Authentication + post '/auth/login', to: 'auth#login' + post '/auth/register', to: 'auth#register' + post '/auth/refresh', to: 'auth#refresh' + delete '/auth/logout', to: 'auth#logout' + post '/auth/forgot_password', to: 'auth#forgot_password' + post '/auth/reset_password', to: 'auth#reset_password' + + # User management + resources :users, only: [:show, :update, :destroy] do + member do + put :change_password + post :verify_email + post :resend_verification + end + end + + # Account management + resource :account, only: [:show, :update] + + # Subscriptions and billing + resources :subscriptions do + resources :payments, only: [:index, :show] + resources :invoices, only: [:index, :show] + end + + # Plans + resources :plans, only: [:index, :show] + + # Administrative endpoints + namespace :admin do + resources :accounts + resources :users + resources :subscriptions + resources :analytics, only: [:index] + end + end + end + + # Webhook endpoints + namespace :webhooks do + post '/stripe', to: 'stripe#handle' + post '/paypal', to: 'paypal#handle' + end +end +``` + +### 8. Service Layer Architecture (MANDATORY) + +#### Service Pattern Implementation +```ruby +# app/services/base_service.rb +class BaseService + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + def self.call(*args, **kwargs) + new(*args, **kwargs).call + end + + def call + raise NotImplementedError, "#{self.class} must implement #call" + end + + protected + + def success(data = {}, message = nil) + ServiceResult.new(success: true, data: data, message: message) + end + + def failure(error, details = {}) + ServiceResult.new(success: false, error: error, details: details) + end +end + +# Service result object +class ServiceResult + attr_reader :data, :error, :message, :details + + def initialize(success:, data: {}, error: nil, message: nil, details: {}) + @success = success + @data = data + @error = error + @message = message + @details = details + end + + def success? + @success + end + + def failure? + !@success + end +end + +# Example service implementation +class SubscriptionCreationService < BaseService + attribute :account, Account + attribute :plan, Plan + attribute :payment_method_id, String + + validates :account, :plan, presence: true + + def call + return failure("Invalid parameters", errors.full_messages) unless valid? + + ActiveRecord::Base.transaction do + subscription = create_subscription + setup_billing + send_welcome_email + + success(subscription: subscription_data(subscription)) + end + rescue StandardError => e + Rails.logger.error "Subscription creation failed: #{e.message}" + failure("Subscription creation failed", { error: e.message }) + end + + private + + def create_subscription + account.subscriptions.create!( + plan: plan, + status: 'active', + current_period_start: Time.current, + current_period_end: 1.month.from_now + ) + end + + def setup_billing + # Delegate to payment service or background job + BillingService.call(subscription: @subscription, payment_method_id: payment_method_id) + end + + def send_welcome_email + UserMailer.subscription_welcome(@subscription.account.users.first, @subscription).deliver_later + end +end +``` + +### 9. Error Handling & Logging (MANDATORY) + +#### Structured Error Handling +```ruby +# app/controllers/concerns/error_handling.rb +module ErrorHandling + extend ActiveSupport::Concern + + included do + rescue_from StandardError, with: :handle_standard_error + rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found + rescue_from ActiveRecord::RecordInvalid, with: :handle_invalid_record + rescue_from JWT::DecodeError, with: :handle_unauthorized + end + + private + + def handle_standard_error(exception) + Rails.logger.error "#{exception.class}: #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") + + render json: { + success: false, + error: "Internal server error" + }, status: :internal_server_error + end + + def handle_not_found(exception) + render json: { + success: false, + error: "Record not found", + details: exception.message + }, status: :not_found + end + + def handle_invalid_record(exception) + render json: { + success: false, + error: "Validation failed", + details: exception.record.errors.full_messages + }, status: :unprocessable_entity + end + + def handle_unauthorized(exception) + render json: { + success: false, + error: "Unauthorized access" + }, status: :unauthorized + end +end +``` + +#### Logging Configuration +```ruby +# config/initializers/logging.rb +Rails.application.configure do + config.log_formatter = proc do |severity, datetime, progname, msg| + { + timestamp: datetime.iso8601, + level: severity, + program: progname, + message: msg, + request_id: Thread.current[:request_id] + }.to_json + "\n" + end + + config.log_tags = [ + :request_id, + -> request { request.remote_ip }, + -> request { Current.user&.id } + ] +end +``` + +### 10. Background Job Integration (MANDATORY) + +#### Sidekiq Configuration +```ruby +# config/initializers/sidekiq.rb +require 'sidekiq/web' + +Sidekiq.configure_server do |config| + config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } + config.queues = %w[critical high default low] +end + +Sidekiq.configure_client do |config| + config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } +end + +# Sidekiq Web UI authentication +Sidekiq::Web.use(Rack::Auth::Basic) do |user, password| + [user, password] == [ENV['SIDEKIQ_USERNAME'], ENV['SIDEKIQ_PASSWORD']] +end +``` + +#### Background Job Integration +```ruby +# app/services/worker_job_service.rb +class WorkerJobService + class WorkerServiceError < StandardError; end + + def self.enqueue_billing_job(job_type, job_data) + begin + response = worker_api_client.post('/jobs', { + job_type: job_type, + job_data: job_data, + queue: 'billing', + retry: true + }) + + unless response.success? + raise WorkerServiceError, "Failed to enqueue job: #{response.error}" + end + + Rails.logger.info "Successfully enqueued #{job_type} job: #{job_data[:id]}" + response.data + rescue => e + Rails.logger.error "Worker service error: #{e.message}" + raise WorkerServiceError, e.message + end + end + + private + + def self.worker_api_client + @worker_api_client ||= WorkerApiClient.new( + base_url: Rails.application.config.worker_url, + token: Rails.application.config.worker_token + ) + end +end +``` + +## Development Commands + +### Rails Application Management +```bash +# Generate new Rails API application +rails new powernode --api --database=postgresql --skip-test + +# Generate controllers, models, migrations +rails generate controller Api::V1::Subscriptions +rails generate model Subscription account:references plan:references +rails generate migration AddIndexToSubscriptions + +# Database operations +rails db:create db:migrate db:seed +rails db:rollback STEP=1 +rails db:reset + +# Server management +rails server -p 3000 +rails console +``` + +### Testing Commands +```bash +# Run backend tests +bundle exec rspec +bundle exec rspec spec/controllers/ +bundle exec rspec spec/models/ + +# Generate test files +rails generate rspec:controller Api::V1::Subscriptions +rails generate rspec:model Subscription +``` + +## Integration Points + +### Rails Architect Coordinates With: +- **Data Modeler**: Database configuration, migration setup +- **API Developer**: Controller patterns, routing configuration +- **Payment Integration Specialist**: Webhook endpoints, security headers +- **Backend Job Engineer**: Sidekiq integration, job delegation +- **Security Specialist**: Middleware configuration, authentication +- **DevOps Engineer**: Environment configuration, deployment setup + +## Quick Reference + +### Controller Template +```ruby +class Api::V1::ResourcesController < Api::V1::BaseController + before_action :set_resource, only: [:show, :update, :destroy] + + def index + resources = current_user.account.resources.page(params[:page]) + render json: { success: true, data: resources.map { |r| resource_data(r) } } + end + + def show + render json: { success: true, data: resource_data(@resource) } + end + + def create + resource = current_user.account.resources.build(resource_params) + + if resource.save + render json: { success: true, data: resource_data(resource) }, status: :created + else + render json: { success: false, error: "Creation failed", details: resource.errors.full_messages }, status: :unprocessable_content + end + end + + private + + def set_resource + @resource = current_user.account.resources.find(params[:id]) + end + + def resource_params + params.require(:resource).permit(:name, :status) + end + + def resource_data(resource) + { id: resource.id, name: resource.name, status: resource.status, created_at: resource.created_at.iso8601 } + end +end +``` diff --git a/docs/examples/BLOG_GENERATION_WORKFLOW.md b/docs/examples/BLOG_GENERATION_WORKFLOW.md new file mode 100644 index 000000000..e0c28ada1 --- /dev/null +++ b/docs/examples/BLOG_GENERATION_WORKFLOW.md @@ -0,0 +1,616 @@ +# Blog Generation Workflow - Complete Example + +**Status**: ✅ Production Ready +**Category**: Content Creation +**Difficulty**: Advanced +**Estimated Time**: 5-10 minutes per blog + +--- + +## 🎯 Overview + +The Blog Generation Workflow is a comprehensive demonstration of Powernode's AI orchestration capabilities, featuring: + +- **6 Specialized AI Agents** working collaboratively +- **Multi-agent communication** with parallel processing +- **Saga pattern** for error recovery and compensation +- **Checkpointing** for long-running operations +- **Conditional logic** with quality gates +- **SEO optimization** and fact-checking + +--- + +## 🤖 AI Agents + +### 1. Research Agent +**Role**: Topic research and data gathering + +**Capabilities**: +- Comprehensive topic research +- Key theme identification +- Statistics and examples compilation +- Source credibility assessment +- Research summary generation + +**Output**: +```json +{ + "main_themes": ["AI automation", "Content quality"], + "key_points": ["Efficiency gains", "Quality assurance"], + "statistics": [{"stat": "40% time saved", "source": "study.com"}], + "examples": ["Company X success story"], + "sources": ["credible-source1.com", "credible-source2.com"], + "research_summary": "Comprehensive findings..." +} +``` + +### 2. Outline Agent +**Role**: Structured blog outline creation + +**Capabilities**: +- SEO-optimized structure +- Logical flow planning +- Keyword placement strategy +- Section planning with word counts +- Meta description generation + +**Output**: +```json +{ + "title": "The Future of AI-Powered Content Creation: A 2025 Guide", + "meta_description": "Discover how AI is transforming content creation...", + "sections": [ + { + "heading": "Understanding AI Content Tools", + "subheadings": ["What is AI Content Generation"], + "key_points": ["Definition", "Key benefits"], + "word_count_target": 300 + } + ], + "target_word_count": 1800 +} +``` + +### 3. Writer Agent +**Role**: Content creation and storytelling + +**Capabilities**: +- Engaging content writing +- Research integration +- Tone consistency +- Storytelling and examples +- Internal linking suggestions + +**Output**: +```json +{ + "full_content": "# Complete markdown blog post...", + "word_count": 1847, + "reading_time": "8 minutes", + "internal_links": ["related-article-1", "related-article-2"] +} +``` + +### 4. Editor Agent +**Role**: Content refinement and quality assurance + +**Capabilities**: +- Grammar and spelling checks +- Clarity improvements +- Flow enhancement +- Readability optimization +- Brand voice consistency + +**Output**: +```json +{ + "edited_content": "# Refined blog post...", + "changes_made": ["Improved transition", "Fixed grammar"], + "quality_score": 92, + "readability_grade": "8th grade", + "suggestions": ["Consider adding example"] +} +``` + +### 5. SEO Agent +**Role**: Search engine optimization + +**Capabilities**: +- Keyword optimization +- Meta tag enhancement +- Schema markup recommendations +- Social media snippets +- URL slug optimization + +**Output**: +```json +{ + "optimized_content": "# SEO-enhanced content...", + "primary_keyword": "AI content creation", + "secondary_keywords": ["content automation", "AI writing"], + "meta_title": "AI Content Creation: Complete 2025 Guide", + "meta_description": "Master AI content creation tools...", + "url_slug": "ai-content-creation-guide-2025", + "schema_markup": {"@type": "Article"}, + "social_snippets": { + "twitter": "Discover the future of AI content...", + "linkedin": "Learn how AI is transforming...", + "facebook": "The complete guide to AI content..." + }, + "seo_score": 94 +} +``` + +### 6. Fact Checker Agent +**Role**: Accuracy verification + +**Capabilities**: +- Fact verification +- Source credibility checks +- Outdated information detection +- Citation improvements +- Bias detection + +**Output**: +```json +{ + "verified_claims": [ + {"claim": "AI saves 40% time", "verified": true, "source": "study.com"} + ], + "unverified_claims": [], + "outdated_info": [], + "credibility_score": 95, + "corrections_needed": [], + "verification_summary": "All claims verified and current" +} +``` + +--- + +## 🔄 Workflow Architecture + +### Workflow Diagram + +``` +┌─────────────┐ +│ Trigger │ Manual input: topic, keywords, audience +│ (Manual) │ +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Research │ Comprehensive topic research +│ Agent │ ✓ Checkpointable +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Outline │ SEO-optimized structure +│ Agent │ ✓ Checkpoint before +└──────┬──────┘ + │ + ├─────────────┬─────────────┐ + ▼ ▼ │ +┌─────────────┐ ┌─────────────┐ │ +│ Writer │ │Fact Checker │ │ Parallel +│ Agent │ │ Agent │ │ Execution +└──────┬──────┘ └──────┬──────┘ │ + │ │ │ + └──────┬──────┘ │ + ▼ │ + ┌─────────────┐ │ + │ Merge │ Combine results + │ Transform │ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ Editor │ Refine and improve + │ Agent │ ✓ Checkpoint after + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ SEO │ Search optimization + │ Agent │ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ Quality │ Score >= 80 && 85? + │ Gate │ (Condition) + └──────┬──────┘ + │ + ┌──────┴──────┐ + │ │ + Pass ▼ Fail ▼ +┌─────────────┐ ┌─────────────┐ +│ Output │ │ Revision │ Improve content +│ Final │ │ Agent │ (loops to SEO) +└─────────────┘ └──────┬──────┘ + │ + (Loop back) +``` + +### Key Features + +**Parallel Processing**: +- Writer and Fact Checker run simultaneously +- Reduces execution time by ~40% +- Results merged before editing + +**Checkpointing**: +- Before outline creation (save research) +- After editing (save refined content) +- Enables recovery and replay + +**Saga Pattern**: +- Each agent step is compensatable +- Automatic rollback on failure +- Preserves workflow integrity + +**Quality Gate**: +- Conditional routing based on scores +- SEO score >= 80 required +- Quality score >= 85 required +- Revision loop if standards not met + +--- + +## 🚀 Usage + +### Installation + +#### Option 1: From Marketplace +```bash +# Via API +POST /api/v1/ai/marketplace/templates/blog-generation-pipeline/install +``` + +#### Option 2: Run Seed File +```bash +cd server +rails db:seed:blog_generation_workflow_seed +``` + +### Execution + +#### Via API +```bash +POST /api/v1/ai/workflows/{workflow_id}/execute +Content-Type: application/json + +{ + "input_data": { + "topic": "The Future of AI-Powered Content Creation", + "target_audience": "content marketers and bloggers", + "keywords": ["AI content", "content automation", "AI writing tools"], + "tone": "professional yet engaging", + "word_count": 1800 + } +} +``` + +#### Via Dashboard +1. Navigate to Workflows +2. Select "Blog Generation Pipeline" +3. Click "Execute" +4. Fill in parameters: + - **Topic**: Your blog subject + - **Target Audience**: Reader demographic + - **Keywords**: SEO keywords (array) + - **Tone**: Writing style + - **Word Count**: Target length + +### Real-Time Monitoring + +```javascript +// Subscribe to workflow execution +const subscription = cable.subscriptions.create( + { channel: "AiWorkflowExecutionChannel", run_id: runId }, + { + received(data) { + console.log('Workflow update:', data); + + // Handle different event types + switch(data.type) { + case 'node_completed': + updateProgress(data.node_id); + break; + case 'checkpoint_created': + console.log('Progress saved at:', data.checkpoint_type); + break; + case 'error_recovery': + console.log('Recovery strategy:', data.strategy); + break; + case 'workflow_completed': + displayResults(data.output); + break; + } + } + } +); +``` + +--- + +## 📊 Expected Output + +### Final Blog Post Structure + +```json +{ + "title": "The Future of AI-Powered Content Creation: A Complete 2025 Guide", + "content": "# Complete markdown blog post with:\n- Introduction\n- Multiple H2/H3 sections\n- Examples and statistics\n- Internal links\n- Conclusion with CTA", + "meta_description": "Discover how AI is transforming content creation in 2025. Learn about AI tools, automation strategies, and best practices for content marketers.", + "url_slug": "ai-content-creation-guide-2025", + "keywords": ["AI content creation", "content automation", "AI writing tools"], + "social_snippets": { + "twitter": "🚀 The future of content is here! Discover how AI is revolutionizing content creation in our complete 2025 guide. #AIContent #ContentMarketing", + "linkedin": "Explore the transformative power of AI in content creation. Our comprehensive guide covers tools, strategies, and best practices for 2025.", + "facebook": "Ready to supercharge your content creation? Learn how AI tools are changing the game for marketers and writers. Read our complete guide!" + }, + "schema_markup": { + "@context": "https://schema.org", + "@type": "Article", + "headline": "The Future of AI-Powered Content Creation", + "author": {"@type": "Organization", "name": "Your Company"} + }, + "reading_time": "8 minutes", + "word_count": 1847, + "quality_metrics": { + "seo_score": 94, + "quality_score": 92, + "credibility_score": 95 + } +} +``` + +--- + +## ⚡ Performance Metrics + +### Execution Statistics + +| Metric | Value | +|--------|-------| +| **Total Duration** | 5-8 minutes | +| **Parallel Savings** | ~40% faster | +| **Average Cost** | $0.50-$2.00 | +| **Success Rate** | 96% | +| **Quality Score** | 90+ average | + +### Agent Performance + +| Agent | Avg Duration | Success Rate | Cost | +|-------|-------------|--------------|------| +| Research | 45-60s | 98% | $0.20 | +| Outline | 30-45s | 97% | $0.15 | +| Writer | 90-120s | 95% | $0.50 | +| Fact Checker | 45-60s | 99% | $0.20 | +| Editor | 60-90s | 96% | $0.40 | +| SEO | 45-60s | 98% | $0.25 | + +--- + +## 🛡️ Error Recovery + +### Saga Pattern Implementation + +**Compensatable Steps**: +1. Research Agent → Rollback research data +2. Outline Agent → Revert to previous outline +3. Writer Agent → Discard draft content +4. Editor Agent → Restore pre-edit version +5. SEO Agent → Remove SEO modifications + +**Recovery Strategies**: +- **Retry with Backoff**: Network errors, API rate limits +- **Checkpoint Rollback**: Major failures during editing +- **Fallback**: Use previous version if revision fails +- **Circuit Breaker**: Prevent cascade failures + +**Example Recovery**: +```json +{ + "error": "API timeout during content writing", + "recovery_strategy": "checkpoint_rollback", + "action": "Rolled back to outline checkpoint", + "retry_count": 1, + "success": true, + "message": "Recovered and completed successfully" +} +``` + +--- + +## 🔧 Customization + +### Modify Agent Prompts + +```ruby +# Update writer agent for different tone +writer_agent.update!( + system_prompt: "You are a casual, friendly content writer..." +) +``` + +### Add Custom Node + +```ruby +# Add image generation node +image_node = AiWorkflowNode.create!( + ai_workflow: workflow, + node_id: 'image_gen_1', + node_type: 'ai_agent', + name: 'Generate Blog Images', + configuration: { + 'agent_id' => image_agent.agent_id, + 'prompt_template' => 'Create 3 blog images for: {{topic}}' + } +) + +# Connect after outline +AiWorkflowEdge.create!( + ai_workflow: workflow, + source_node_id: 'outline_1', + target_node_id: 'image_gen_1' +) +``` + +### Adjust Quality Threshold + +```ruby +# Update quality gate +quality_gate_node.update!( + configuration: { + 'condition' => 'seo_score >= 90 && quality_score >= 90' + } +) +``` + +--- + +## 📈 Analytics & Insights + +### Track Performance + +```bash +GET /api/v1/ai/workflows/{workflow_id}/analytics/dashboard?time_range=month +``` + +**Response**: +```json +{ + "overview": { + "total_executions": 156, + "successful_executions": 149, + "average_duration": 385000, + "total_cost": 187.50, + "success_rate": 95.51 + }, + "node_analytics": { + "critical_nodes": ["writer_1", "editor_1"], + "slow_nodes": ["writer_1"], + "expensive_nodes": ["writer_1", "editor_1"] + }, + "optimization_opportunities": [ + { + "type": "caching", + "description": "Cache research results for similar topics", + "potential_savings": "$45.00/month" + } + ] +} +``` + +--- + +## 🎯 Use Cases + +### 1. Content Marketing Teams +- Generate multiple blog posts per week +- Maintain consistent quality and tone +- Optimize for SEO automatically +- Scale content production + +### 2. Technical Documentation +- Modify for technical writing tone +- Add code example generation +- Include API documentation nodes +- Ensure technical accuracy + +### 3. Thought Leadership +- Deep research and insights +- Executive tone and voice +- High-quality editing +- LinkedIn optimization + +### 4. Educational Content +- Simplified language +- Example-heavy content +- Fact-checking emphasis +- Student-friendly formatting + +--- + +## 🔗 Integration Examples + +### WordPress Integration + +```javascript +// After workflow completion +async function publishToWordPress(blogPost) { + const response = await fetch('https://yoursite.com/wp-json/wp/v2/posts', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${wpToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: blogPost.title, + content: blogPost.content, + excerpt: blogPost.meta_description, + slug: blogPost.url_slug, + meta: { + _yoast_wpseo_metadesc: blogPost.meta_description, + _yoast_wpseo_focuskw: blogPost.keywords[0] + }, + status: 'draft' + }) + }); + + return response.json(); +} +``` + +### CMS Integration + +```ruby +# Publish to custom CMS +class BlogPublisher + def publish(workflow_output) + BlogPost.create!( + title: workflow_output['title'], + content: workflow_output['content'], + meta_description: workflow_output['meta_description'], + slug: workflow_output['url_slug'], + keywords: workflow_output['keywords'], + reading_time: workflow_output['reading_time'], + seo_score: workflow_output['quality_metrics']['seo_score'], + status: 'draft' + ) + end +end +``` + +--- + +## 📚 Additional Resources + +### Related Documentation +- [AI Orchestration Guide](../platform/AI_ORCHESTRATION_GUIDE.md) +- [Workflow System Standards](../platform/WORKFLOW_SYSTEM_STANDARDS.md) + +### API Endpoints +- `POST /api/v1/ai/workflows/{id}/execute` - Execute workflow +- `GET /api/v1/ai/workflows/{id}/analytics/dashboard` - View analytics +- `GET /api/v1/ai/marketplace/templates/blog-generation-pipeline` - Template details + +--- + +## 🎉 Success Stories + +> "We reduced blog production time from 8 hours to 30 minutes while improving SEO scores by 40%." +> — Content Marketing Manager + +> "The quality gate ensures every post meets our standards before publication. Game changer!" +> — Editorial Director + +> "Parallel fact-checking saved us from publishing incorrect statistics. Worth every penny." +> — Research Team Lead + +--- + +*Created: October 2025* +*Platform Version: 0.3.1* diff --git a/docs/examples/BLOG_WORKFLOW_QUICK_START.md b/docs/examples/BLOG_WORKFLOW_QUICK_START.md new file mode 100644 index 000000000..aee078f51 --- /dev/null +++ b/docs/examples/BLOG_WORKFLOW_QUICK_START.md @@ -0,0 +1,415 @@ +# Blog Generation Workflow - Quick Start Guide + +**⏱️ 5-Minute Setup** | **🚀 Production Ready** | **💰 $0.50-$2 per blog** + +--- + +## 🎯 What You'll Get + +A **fully automated blog generation pipeline** that produces: + +✅ SEO-optimized, publication-ready blog posts +✅ Fact-checked content with verified sources +✅ Professional editing and refinement +✅ Social media snippets +✅ Schema markup for rich snippets +✅ Quality scores (SEO, readability, credibility) + +**In 5-10 minutes** instead of 8+ hours of manual work. + +--- + +## 🚀 Quick Setup + +### Step 1: Install Template (30 seconds) + +```bash +# Option A: Via Marketplace UI +1. Go to AI Marketplace +2. Search "Blog Generation Pipeline" +3. Click "Install" + +# Option B: Via API +curl -X POST https://api.powernode.ai/v1/ai/marketplace/templates/blog-generation-pipeline/install \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Option C: Via Seed File +cd server && rails db:seed:blog_generation_workflow_seed +``` + +### Step 2: Configure API Key (1 minute) + +```bash +# Add Anthropic API key +POST /api/v1/ai/credentials +{ + "provider_type": "anthropic", + "api_key": "sk-ant-...", + "name": "Claude API" +} +``` + +### Step 3: Execute (30 seconds) + +```bash +POST /api/v1/ai/workflows/{workflow_id}/execute +{ + "input_data": { + "topic": "Your Blog Topic Here", + "keywords": ["keyword1", "keyword2"], + "word_count": 1500 + } +} +``` + +**That's it!** 🎉 Your blog is being generated. + +--- + +## 📊 Workflow Architecture + +``` +INPUT → Research → Outline → [Write + FactCheck] → Edit → SEO → Quality Check → OUTPUT + ↓ ↓ + (Parallel) (Conditional) + ↓ + Revision Loop +``` + +### 6 AI Agents Working Together + +| Agent | Job | Output | +|-------|-----|--------| +| 🔍 **Research** | Gather data, stats, examples | Research summary | +| 📝 **Outline** | Create SEO-optimized structure | Blog outline | +| ✍️ **Writer** | Generate engaging content | Full blog post | +| ✓ **Fact Checker** | Verify accuracy (parallel) | Verified claims | +| 📐 **Editor** | Refine and improve | Polished content | +| 🎯 **SEO** | Optimize for search | SEO-ready post | + +--- + +## 🎨 Input Parameters + +### Required +```json +{ + "topic": "Your blog topic or title" +} +``` + +### Optional (with smart defaults) +```json +{ + "target_audience": "content marketers", + "keywords": ["AI content", "automation"], + "tone": "professional", + "word_count": 1500, + "quality_threshold": 85 +} +``` + +--- + +## 📤 Output Format + +```json +{ + "title": "SEO-Optimized Title with Primary Keyword", + "content": "# Complete markdown blog post\n\nIntro...", + "meta_description": "Compelling 155-character description", + "url_slug": "seo-friendly-url-slug", + "keywords": ["primary", "secondary", "keywords"], + "social_snippets": { + "twitter": "Engaging tweet with hashtags", + "linkedin": "Professional LinkedIn post", + "facebook": "Facebook-optimized snippet" + }, + "schema_markup": { + "@type": "Article", + "headline": "...", + "author": "..." + }, + "reading_time": "8 minutes", + "word_count": 1847, + "quality_metrics": { + "seo_score": 94, + "quality_score": 92, + "credibility_score": 95 + } +} +``` + +--- + +## 🔥 Advanced Features + +### Parallel Processing +- **Writer** and **Fact Checker** run simultaneously +- **40% faster** execution + +### Checkpointing +- Auto-save after research +- Auto-save after editing +- **Resume from any point** if interrupted + +### Quality Gate +- Automatic quality check +- **Revision loop** if scores below threshold +- Ensures consistent output quality + +### Error Recovery +- Saga pattern with **automatic compensation** +- **Self-healing** on failures +- Circuit breaker for API issues + +--- + +## 💡 Use Cases + +### 1. Content Marketing +```json +{ + "topic": "10 Content Marketing Trends for 2025", + "target_audience": "marketing managers", + "tone": "authoritative yet accessible", + "word_count": 2000 +} +``` +**→ Professional thought leadership piece** + +### 2. Technical Blog +```json +{ + "topic": "Introduction to Microservices Architecture", + "target_audience": "developers", + "tone": "technical", + "word_count": 1800 +} +``` +**→ Developer-focused educational content** + +### 3. Product Blog +```json +{ + "topic": "How AI Improves Customer Support", + "target_audience": "business owners", + "keywords": ["AI support", "customer service automation"], + "tone": "conversational" +} +``` +**→ Product marketing content** + +--- + +## 📈 Real-Time Monitoring + +### Via WebSocket +```javascript +cable.subscriptions.create( + { channel: "AiWorkflowExecutionChannel", run_id: runId }, + { + received(data) { + if (data.type === 'node_completed') { + console.log(`${data.node_name} completed`); + updateProgress(data.progress_percent); + } + } + } +); +``` + +### Progress Updates +``` +✓ Research completed (20%) +✓ Outline created (35%) +✓ Content written (50%) +✓ Facts verified (65%) +✓ Content edited (80%) +✓ SEO optimized (95%) +✓ Quality check passed (100%) +``` + +--- + +## 💰 Cost & Performance + +| Metric | Value | +|--------|-------| +| **Execution Time** | 5-8 minutes | +| **Average Cost** | $0.50-$2.00 | +| **Success Rate** | 96%+ | +| **Quality Score** | 90+ average | +| **Time Saved** | ~7.5 hours vs manual | +| **Cost Savings** | ~$150 vs freelancer | + +--- + +## 🛠️ Customization + +### Change Tone +```ruby +# Update writer agent +writer_agent.update!( + system_prompt: "You are a casual, friendly blogger..." +) +``` + +### Adjust Word Count +```json +{ + "topic": "Your topic", + "word_count": 2500 // Longer form +} +``` + +### Add Custom Agent +```ruby +# Example: Add image generator +image_agent = AiAgent.create!( + name: 'Image Generator', + agent_type: 'image_generator', + ai_provider: dall_e_provider +) + +# Add to workflow +AiWorkflowNode.create!( + ai_workflow: workflow, + node_type: 'ai_agent', + configuration: { agent_id: image_agent.agent_id } +) +``` + +--- + +## 📊 Analytics Dashboard + +```bash +GET /api/v1/ai/workflows/{workflow_id}/analytics/dashboard +``` + +**See**: +- Total executions and success rate +- Average duration and cost +- Node performance breakdown +- Optimization opportunities +- Cost savings recommendations + +--- + +## 🔗 Integration Examples + +### WordPress Auto-Publish +```javascript +async function autoPublish(result) { + // Workflow completes → Auto-publish to WordPress + await publishToWP({ + title: result.title, + content: result.content, + slug: result.url_slug, + meta: result.meta_description + }); +} +``` + +### Slack Notification +```ruby +# After completion +SlackNotifier.notify( + channel: '#content-team', + message: "New blog post ready: #{result['title']}" +) +``` + +### Email Draft +```ruby +# Send to editor +Mailer.send_draft( + to: 'editor@company.com', + subject: "Review: #{result['title']}", + content: result['content'] +) +``` + +--- + +## 🆘 Troubleshooting + +### Low Quality Scores? +```json +// Increase quality threshold +{ + "quality_threshold": 90 // Higher bar +} +``` + +### Content Too Short? +```json +{ + "word_count": 2500 // Increase target +} +``` + +### SEO Score Low? +```json +{ + "keywords": ["primary", "secondary", "tertiary"], // More keywords + "topic": "Include primary keyword in topic" +} +``` + +### API Timeout? +- Checkpointing enabled → Will resume +- Saga pattern → Automatic recovery +- Check `/recovery/statistics` for details + +--- + +## 📚 Next Steps + +1. **Review Output**: Check the generated blog post +2. **Customize Agents**: Adjust prompts for your needs +3. **Add Integrations**: Connect to WordPress, CMS, etc. +4. **Scale Up**: Run multiple blogs in parallel +5. **Analyze Performance**: Use analytics dashboard + +--- + +## 🎯 Pro Tips + +✨ **Use specific topics**: "10 Ways AI Improves Marketing" beats "AI and Marketing" + +✨ **Provide keywords**: Better SEO when you specify target keywords + +✨ **Set audience**: Content adapts to target reader demographic + +✨ **Monitor costs**: Check analytics for optimization opportunities + +✨ **Batch process**: Queue multiple blogs during off-hours + +✨ **Template variations**: Clone and customize for different content types + +--- + +## 📞 Support + +- **Documentation**: [Full Workflow Guide](BLOG_GENERATION_WORKFLOW.md) +- **API Docs**: [AI Orchestration Guide](../platform/AI_ORCHESTRATION_GUIDE.md) +- **Examples**: See seed file for implementation details + +--- + +**Ready to 10x your content production?** 🚀 + +Run the seed file and generate your first AI blog in 5 minutes! + +```bash +cd server +rails db:seed:blog_generation_workflow_seed + +# Then execute via API or dashboard +``` + +--- + +*Platform Version: 0.3.1 | Powered by Powernode AI Orchestration* diff --git a/docs/examples/services/modular_workflow_execution_example.rb b/docs/examples/services/modular_workflow_execution_example.rb new file mode 100644 index 000000000..0691f7512 --- /dev/null +++ b/docs/examples/services/modular_workflow_execution_example.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +# Example: Using the Modular Workflow Orchestration Services +# +# This example demonstrates how to use the new modular workflow services +# instead of the monolithic WorkflowOrchestrator. The modular approach +# provides better separation of concerns and easier testing. +# +# Services Used: +# - Mcp::WorkflowExecutor: Handles node execution and flow control +# - Mcp::WorkflowStateManager: Manages state transitions +# - Mcp::WorkflowEventStore: Records all execution events +# +# Benefits of Modular Approach: +# - Each service has single responsibility +# - Services can be tested independently +# - State and event management can be customized +# - Easier to understand and maintain + +module Examples + module Services + # ModularWorkflowExecutionExample - Shows how to orchestrate workflows using modular services + class ModularWorkflowExecutionExample + def initialize(workflow_run) + @workflow_run = workflow_run + end + + # ============================================================================= + # BASIC USAGE: Execute workflow with all components + # ============================================================================= + + def basic_execution + # Create component services + state_manager = Mcp::WorkflowStateManager.new(workflow_run: @workflow_run) + event_store = Mcp::WorkflowEventStore.new(workflow_run: @workflow_run) + executor = Mcp::WorkflowExecutor.new( + workflow_run: @workflow_run, + state_manager: state_manager, + event_store: event_store + ) + + # Execute workflow + result = executor.execute + + # Access events after execution + timeline = event_store.build_timeline + summary = event_store.execution_summary + + puts "Execution completed!" + puts " Nodes executed: #{result[:node_count]}" + puts " Duration: #{result[:duration_ms]}ms" + puts " Total events: #{event_store.event_count}" + puts " Timeline entries: #{timeline.count}" + + { result: result, timeline: timeline, summary: summary } + end + + # ============================================================================= + # ADVANCED USAGE: Custom state management + # ============================================================================= + + def execution_with_custom_state_tracking + # Create services + state_manager = Mcp::WorkflowStateManager.new(workflow_run: @workflow_run) + event_store = Mcp::WorkflowEventStore.new(workflow_run: @workflow_run) + + # Initialize workflow + puts "Initializing workflow..." + state_manager.transition!(:pending, :initializing) + + # Record custom event + event_store.record_event( + event_type: 'workflow.custom.initialized', + event_data: { + message: 'Custom initialization complete', + custom_field: 'example value' + } + ) + + # Create executor + executor = Mcp::WorkflowExecutor.new( + workflow_run: @workflow_run, + state_manager: state_manager, + event_store: event_store + ) + + # Execute + result = executor.execute + + # Check final state + puts "Final state: #{state_manager.current_state}" + puts "Is terminal? #{state_manager.terminal_state?}" + + result + end + + # ============================================================================= + # ERROR HANDLING: Graceful failure with event recording + # ============================================================================= + + def execution_with_error_handling + state_manager = Mcp::WorkflowStateManager.new(workflow_run: @workflow_run) + event_store = Mcp::WorkflowEventStore.new(workflow_run: @workflow_run) + executor = Mcp::WorkflowExecutor.new( + workflow_run: @workflow_run, + state_manager: state_manager, + event_store: event_store + ) + + begin + result = executor.execute + + puts "Success! Final status: #{result[:status]}" + result + + rescue Mcp::WorkflowExecutor::ExecutionError => e + puts "Execution failed: #{e.message}" + + # State should be 'failed' + puts "Current state: #{state_manager.current_state}" + + # Get failure events + error_events = event_store.get_events_by_type('node.execution.failed') + puts "Failed nodes: #{error_events.count}" + + # Get execution summary + summary = event_store.execution_summary + puts "Total errors: #{summary[:errors]}" + + # Export events for debugging + event_export = event_store.export_events(format: :json) + File.write('/tmp/workflow_failure_events.json', event_export) + puts "Events exported to /tmp/workflow_failure_events.json" + + raise + end + end + + # ============================================================================= + # MONITORING: Track execution progress + # ============================================================================= + + def execution_with_monitoring + state_manager = Mcp::WorkflowStateManager.new(workflow_run: @workflow_run) + event_store = Mcp::WorkflowEventStore.new(workflow_run: @workflow_run) + + # Monitor state changes + state_changes = [] + original_transition = state_manager.method(:transition!) + + state_manager.define_singleton_method(:transition!) do |from, to| + state_changes << { from: from, to: to, at: Time.current } + original_transition.call(from, to) + end + + # Execute + executor = Mcp::WorkflowExecutor.new( + workflow_run: @workflow_run, + state_manager: state_manager, + event_store: event_store + ) + + result = executor.execute + + # Analyze execution + puts "\nExecution Analysis:" + puts "=" * 50 + + # State transitions + puts "\nState Transitions:" + state_changes.each do |change| + puts " #{change[:from]} → #{change[:to]} at #{change[:at].strftime('%H:%M:%S')}" + end + + # Event distribution + puts "\nEvent Distribution:" + event_counts = event_store.get_events.group_by { |e| e[:event_type] } + .transform_values(&:count) + event_counts.each do |type, count| + puts " #{type}: #{count}" + end + + # Timeline + puts "\nExecution Timeline:" + timeline = event_store.build_timeline + timeline.first(5).each do |entry| + puts " [#{entry[:sequence]}] #{entry[:timestamp].strftime('%H:%M:%S.%L')} - #{entry[:summary]}" + end + + result + end + + # ============================================================================= + # TESTING: Inject mock services + # ============================================================================= + + def execution_with_mocked_services + # Create mock state manager + mock_state_manager = double('StateManager') + allow(mock_state_manager).to receive(:transition!) + allow(mock_state_manager).to receive(:execute_node) + allow(mock_state_manager).to receive(:transition_to_completed) + allow(mock_state_manager).to receive(:transition_to_failed) + + # Create mock event store + mock_event_store = double('EventStore') + allow(mock_event_store).to receive(:record_event) + allow(mock_event_store).to receive(:record_node_started) + allow(mock_event_store).to receive(:record_node_completed) + allow(mock_event_store).to receive(:record_node_failed) + allow(mock_event_store).to receive(:record_execution_failed) + allow(mock_event_store).to receive(:event_count).and_return(0) + + # Create executor with mocks + executor = Mcp::WorkflowExecutor.new( + workflow_run: @workflow_run, + state_manager: mock_state_manager, + event_store: mock_event_store + ) + + # Execute + result = executor.execute + + # Verify interactions + expect(mock_state_manager).to have_received(:transition!).at_least(:once) + expect(mock_event_store).to have_received(:record_event).at_least(:once) + + puts "Mock execution completed successfully" + result + end + + # ============================================================================= + # COMPARISON: Monolithic vs Modular + # ============================================================================= + + def comparison_example + puts "\n" + "=" * 80 + puts "COMPARISON: Monolithic vs Modular Orchestration" + puts "=" * 80 + + # OLD WAY: Monolithic orchestrator + puts "\nOLD WAY (Monolithic):" + puts "-" * 80 + puts <<~OLD + # Single 917-line class handles everything + orchestrator = Mcp::WorkflowOrchestrator.new(workflow_run: run) + result = orchestrator.execute + + # Problems: + # - Can't customize state management + # - Can't test components independently + # - Hard to understand with 900+ lines + # - Violates Single Responsibility Principle + # - Tight coupling between concerns + OLD + + # NEW WAY: Modular services + puts "\nNEW WAY (Modular):" + puts "-" * 80 + puts <<~NEW + # Separate focused services + state_manager = Mcp::WorkflowStateManager.new(workflow_run: run) + event_store = Mcp::WorkflowEventStore.new(workflow_run: run) + executor = Mcp::WorkflowExecutor.new( + workflow_run: run, + state_manager: state_manager, + event_store: event_store + ) + result = executor.execute + + # Benefits: + # ✅ Each service < 450 lines, focused on one concern + # ✅ Can customize any component + # ✅ Easy to test independently + # ✅ Clear separation of concerns + # ✅ Follows Single Responsibility Principle + # ✅ Services use shared base abstractions + NEW + + puts "\n" + "=" * 80 + puts "Code Metrics Comparison:" + puts "-" * 80 + puts "Monolithic Orchestrator: 917 lines (1 file)" + puts "Modular Services: 1150 lines (3 files)" + puts " - WorkflowExecutor: 420 lines" + puts " - WorkflowStateManager: 280 lines" + puts " - WorkflowEventStore: 450 lines" + puts "\nBenefits:" + puts "✅ Better separation of concerns" + puts "✅ Easier to test (can mock individual services)" + puts "✅ Easier to understand (each file < 500 lines)" + puts "✅ More flexible (can customize components)" + puts "✅ Follows SOLID principles" + puts "=" * 80 + end + end + end +end + +# ============================================================================= +# USAGE EXAMPLES +# ============================================================================= + +# Example 1: Basic execution +# workflow_run = AiWorkflowRun.find(params[:id]) +# example = Examples::Services::ModularWorkflowExecutionExample.new(workflow_run) +# result = example.basic_execution + +# Example 2: With error handling +# result = example.execution_with_error_handling + +# Example 3: With monitoring +# result = example.execution_with_monitoring + +# Example 4: View comparison +# example.comparison_example diff --git a/docs/frontend/ADMIN_PANEL_DEVELOPER_SPECIALIST.md b/docs/frontend/ADMIN_PANEL_DEVELOPER_SPECIALIST.md new file mode 100644 index 000000000..c9695b7e9 --- /dev/null +++ b/docs/frontend/ADMIN_PANEL_DEVELOPER_SPECIALIST.md @@ -0,0 +1,1420 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# Admin Panel Developer Specialist Guide + +## Related References + +For common patterns used across multiple specialists, see these consolidated references: +- **[Permission System Reference](../platform/PERMISSION_SYSTEM_REFERENCE.md)** - Permission-based access control for admin features +- **[Theme System Reference](../platform/THEME_SYSTEM_REFERENCE.md)** - Theme-aware styling classes + +## Role & Responsibilities + +The Admin Panel Developer specializes in creating comprehensive administrative interfaces, system management panels, and complex data management tools for Powernode's subscription platform. + +### Core Responsibilities +- Creating admin dashboard interfaces +- Implementing customer management tools +- Building subscription administration features +- Creating system configuration panels +- Handling bulk operations and exports + +### Key Focus Areas +- Complex data management interfaces +- Role-based access control for admin features +- Bulk operations and batch processing UI +- Advanced filtering and search capabilities +- System monitoring and health dashboards + +## Admin Panel Architecture Standards + +### 1. Page Layout Requirements (CRITICAL) + +**ABSOLUTE PROHIBITION**: Admin tab pages MUST NOT create duplicate PageContainer or TabContainer structures. + +#### Mandatory Page Structure Pattern +```tsx +// ❌ FORBIDDEN: Admin tab page with duplicate containers +const AdminSettingsTabPage: React.FC = () => { + return ( + + + + + ); +}; + +// ✅ CORRECT: Admin tab page returns component directly +const AdminSettingsTabPage: React.FC = () => { + const { user } = useSelector((state: RootState) => state.auth); + const canAccessSettings = hasPermissions(user, ['admin.settings.manage']); + + if (!canAccessSettings) { + return ; + } + + return ; +}; +``` + +**Page Layout Enforcement Rules**: +1. **Parent AdminSettingsPage** provides PageContainer and AdminSettingsTabs +2. **Child tab pages** return component content directly - NO containers +3. **Permission checks** must be performed at tab level +4. **Navigation redirects** for unauthorized access + +#### Admin Settings Tab Structure (MANDATORY) +```tsx +// Parent AdminSettingsPage.tsx - ONLY place for containers +export const AdminSettingsPage: React.FC = () => { + return ( + + {/* Navigation tabs */} +
+ {/* Tab content rendered here */} +
+
+ ); +}; + +// Child tab pages - NO containers, direct component return +export const AdminSettingsGeneralTabPage: React.FC = () => { + return ; +}; + +export const AdminSettingsSecurityTabPage: React.FC = () => { + return ; +}; + +export const AdminSettingsRateLimitingTabPage: React.FC = () => { + const { user } = useSelector((state: RootState) => state.auth); + const canManageRateLimiting = hasPermissions(user, ['admin.settings.security']); + + if (!canManageRateLimiting) { + return ; + } + + return ; +}; +``` + +### 2. Component Standards (MANDATORY) + +#### Standard Button Component Usage +**CRITICAL**: All interactive elements MUST use the standard Button component from `@/shared/components/ui/Button`. + +```tsx +// ❌ FORBIDDEN: Custom button implementations + + +// ✅ CORRECT: Standard Button component + +``` + +#### Button Variant Standards +```tsx +// Primary actions + + + +// Secondary actions + + + +// Danger actions + + + +// Success actions + + +``` + +### 3. Theme-Aware Styling (CRITICAL) + +#### MANDATORY Theme Classes +**ABSOLUTE PROHIBITION**: Hardcoded color classes are FORBIDDEN except `text-white` on colored backgrounds. + +```tsx +// ❌ FORBIDDEN: Hardcoded colors +className="bg-yellow-100 border-yellow-400 text-yellow-800" +className="bg-red-500 text-white border-red-600" +className="bg-green-50 border-green-200" + +// ✅ CORRECT: Theme-aware classes +className="bg-theme-warning-background border-theme-warning text-theme-warning-dark" +className="bg-theme-error border-theme-error text-white" +className="bg-theme-success-background border-theme-success" +``` + +#### Standard Theme Color Mapping +```typescript +const THEME_CLASSES = { + // Backgrounds + surface: 'bg-theme-surface', + background: 'bg-theme-background', + surfaceSubtle: 'bg-theme-surface-subtle', + + // State backgrounds + warningBg: 'bg-theme-warning-background', + errorBg: 'bg-theme-error-background', + successBg: 'bg-theme-success-background', + + // Text colors + primary: 'text-theme-primary', + secondary: 'text-theme-secondary', + tertiary: 'text-theme-tertiary', + + // State text colors + error: 'text-theme-error', + errorDark: 'text-theme-error-dark', + warning: 'text-theme-warning', + warningDark: 'text-theme-warning-dark', + success: 'text-theme-success', + successDark: 'text-theme-success-dark', + + // Borders + border: 'border-theme', + errorBorder: 'border-theme-error', + warningBorder: 'border-theme-warning', + successBorder: 'border-theme-success' +} as const; +``` + +#### Form Input Standards (ACCESSIBILITY CRITICAL) +**MANDATORY**: All form inputs must provide sufficient contrast and theme-aware styling. + +```tsx +// ✅ CORRECT: Theme-aware form input with accessibility + + +// ✅ CORRECT: Emergency control input with proper contrast + +``` + +### 4. Accessibility Compliance (WCAG AA MANDATORY) + +#### Contrast Requirements +- **Text contrast ratio**: Minimum 4.5:1 for normal text, 3:1 for large text +- **Interactive elements**: Minimum 3:1 contrast for focus states +- **Form inputs**: Must have sufficient contrast between text and background + +#### ARIA and Semantic HTML +```tsx +// ✅ CORRECT: Proper ARIA labels and semantic structure +
+

+ Rate Limiting Settings +

+ +
+

+ Emergency Controls +

+ + + +

+ Temporarily disable rate limiting for maintenance +

+
+
+``` + +#### Focus Management +```tsx +// ✅ CORRECT: Visible focus states with theme awareness +className="focus:ring-2 focus:ring-theme-primary focus:ring-offset-2 focus:outline-none" + +// ✅ CORRECT: Focus trap in modals +const focusableElements = modalRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' +); +``` + +### 5. Admin Layout Structure (MANDATORY) + +#### Admin-Specific Layout Component +```tsx +// src/shared/components/layout/AdminLayout.tsx +import React, { useState } from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '@/features/auth/hooks/useAuth'; +import { usePermissions } from '@/shared/hooks/usePermissions'; +import { AdminSidebar } from './AdminSidebar'; +import { AdminHeader } from './AdminHeader'; +import { AdminBreadcrumb } from './AdminBreadcrumb'; +import { SystemHealthIndicator } from '@/features/admin/components/SystemHealthIndicator'; + +export const AdminLayout: React.FC = () => { + const [sidebarOpen, setSidebarOpen] = useState(false); + const { user } = useAuth(); + const { hasPermission } = usePermissions(); + const navigate = useNavigate(); + const location = useLocation(); + + // Redirect if no admin access + if (!hasPermission('admin.access')) { + navigate('/unauthorized'); + return null; + } + + return ( +
+ {/* Mobile sidebar */} +
+
setSidebarOpen(false)} /> + setSidebarOpen(false)} /> +
+ + {/* Desktop sidebar */} +
+ +
+ + {/* Main content */} +
+ {/* Top navigation */} + setSidebarOpen(!sidebarOpen)} + user={user} + /> + + {/* Page content */} +
+ {/* System health indicator */} +
+ +
+ + {/* Breadcrumb */} +
+ +
+ + {/* Main content area */} +
+ +
+
+
+
+ ); +}; + +// src/shared/components/layout/AdminSidebar.tsx +interface AdminSidebarProps { + onClose?: () => void; +} + +export const AdminSidebar: React.FC = ({ onClose }) => { + const location = useLocation(); + const { hasPermission } = usePermissions(); + + const navigationItems = [ + { + name: 'Dashboard', + href: '/app/admin', + icon: '📊', + permission: 'admin.access' + }, + { + name: 'User Management', + href: '/app/admin/users', + icon: '👥', + permission: 'admin.users.manage' + }, + { + name: 'Account Management', + href: '/app/admin/accounts', + icon: '🏢', + permission: 'admin.accounts.manage' + }, + { + name: 'Subscription Management', + href: '/app/admin/subscriptions', + icon: '💳', + permission: 'admin.subscriptions.manage' + }, + { + name: 'System Settings', + href: '/app/admin/settings', + icon: '⚙️', + permission: 'admin.settings.manage' + }, + { + name: 'Audit Logs', + href: '/app/admin/audit-logs', + icon: '📋', + permission: 'admin.audit.read' + }, + { + name: 'Performance', + href: '/app/admin/performance', + icon: '📈', + permission: 'admin.performance.read' + }, + { + name: 'Maintenance', + href: '/app/admin/maintenance', + icon: '🔧', + permission: 'admin.maintenance.access' + } + ]; + + const filteredItems = navigationItems.filter(item => + hasPermission(item.permission) + ); + + return ( +
+ {/* Header */} +
+

Admin Panel

+ {onClose && ( + + )} +
+ + {/* Navigation */} + + + {/* Footer */} +
+
+ Admin Panel v1.0 +
+
+
+ ); +}; +``` + +### 2. Data Management Components (MANDATORY) + +#### Advanced Data Table with Admin Features +```tsx +// src/features/admin/components/AdminDataTable.tsx +import React, { useState, useMemo } from 'react'; +import { Table, Column, Pagination } from '@/shared/components/data-display/Table'; +import { Button } from '@/shared/components/ui/Button'; +import { Input } from '@/shared/components/ui/Input'; +import { Select } from '@/shared/components/ui/Select'; +import { Checkbox } from '@/shared/components/ui/Checkbox'; +import { Modal } from '@/shared/components/ui/Modal'; +import { useDebounce } from '@/shared/hooks/useDebounce'; + +interface AdminDataTableProps { + data: T[]; + columns: Column[]; + loading?: boolean; + totalCount: number; + currentPage: number; + itemsPerPage: number; + onPageChange: (page: number) => void; + onItemsPerPageChange: (itemsPerPage: number) => void; + onSort?: (column: keyof T, direction: 'asc' | 'desc') => void; + onSearch?: (query: string) => void; + onFilter?: (filters: Record) => void; + onBulkAction?: (action: string, selectedIds: string[]) => void; + onExport?: (format: 'csv' | 'xlsx') => void; + bulkActions?: Array<{ label: string; value: string; danger?: boolean }>; + filters?: Array<{ + key: string; + label: string; + type: 'select' | 'date' | 'text'; + options?: Array<{ label: string; value: string }>; + }>; + searchPlaceholder?: string; + selectable?: boolean; +} + +export function AdminDataTable({ + data, + columns, + loading, + totalCount, + currentPage, + itemsPerPage, + onPageChange, + onItemsPerPageChange, + onSort, + onSearch, + onFilter, + onBulkAction, + onExport, + bulkActions = [], + filters = [], + searchPlaceholder = "Search...", + selectable = true +}: AdminDataTableProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBulkConfirm, setShowBulkConfirm] = useState(false); + const [pendingBulkAction, setPendingBulkAction] = useState(''); + const [currentFilters, setCurrentFilters] = useState>({}); + + const debouncedSearch = useDebounce(searchQuery, 300); + + // Handle search + React.useEffect(() => { + onSearch?.(debouncedSearch); + }, [debouncedSearch, onSearch]); + + // Selection handlers + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedIds(new Set(data.map(item => item.id))); + } else { + setSelectedIds(new Set()); + } + }; + + const handleSelectItem = (id: string, checked: boolean) => { + const newSelected = new Set(selectedIds); + if (checked) { + newSelected.add(id); + } else { + newSelected.delete(id); + } + setSelectedIds(newSelected); + }; + + // Bulk action handlers + const handleBulkAction = (action: string) => { + if (selectedIds.size === 0) return; + + setPendingBulkAction(action); + setShowBulkConfirm(true); + }; + + const confirmBulkAction = () => { + onBulkAction?.(pendingBulkAction, Array.from(selectedIds)); + setSelectedIds(new Set()); + setShowBulkConfirm(false); + setPendingBulkAction(''); + }; + + // Filter handlers + const handleFilterChange = (key: string, value: any) => { + const newFilters = { ...currentFilters, [key]: value }; + if (!value) { + delete newFilters[key]; + } + setCurrentFilters(newFilters); + onFilter?.(newFilters); + }; + + // Enhanced columns with selection + const enhancedColumns = useMemo(() => { + const cols = [...columns]; + + if (selectable) { + cols.unshift({ + key: 'select' as keyof T, + header: ( + 0 && selectedIds.size === data.length} + indeterminate={selectedIds.size > 0 && selectedIds.size < data.length} + onChange={handleSelectAll} + /> + ), + width: '50px', + render: (_, row) => ( + handleSelectItem(row.id, checked)} + /> + ) + }); + } + + return cols; + }, [columns, data.length, selectedIds, selectable]); + + const allSelected = data.length > 0 && selectedIds.size === data.length; + const someSelected = selectedIds.size > 0 && selectedIds.size < data.length; + + return ( +
+ {/* Controls */} +
+
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + leftIcon={} + /> +
+ + {/* Filters */} + {filters.length > 0 && ( +
+ {filters.map((filter) => ( +
+ {filter.type === 'select' ? ( + handleFilterChange(filter.key, e.target.value)} + type={filter.type === 'date' ? 'date' : 'text'} + /> + )} +
+ ))} +
+ )} + + {/* Export */} + {onExport && ( +
+ + +
+ )} +
+ + {/* Bulk actions */} + {selectable && selectedIds.size > 0 && ( +
+
+ {selectedIds.size} item{selectedIds.size !== 1 ? 's' : ''} selected +
+ +
+ {bulkActions.map((action) => ( + + ))} +
+
+ )} +
+ + {/* Data table */} + + + {/* Pagination */} + + + {/* Bulk action confirmation */} + +
+

+ Are you sure you want to perform "{pendingBulkAction}" on {selectedIds.size} selected items? + This action cannot be undone. +

+ +
+ + +
+
+
+ + ); +} +``` + +#### User Management Interface +```tsx +// src/features/admin/components/UserManagement.tsx +import React, { useState } from 'react'; +import { useApi } from '@/shared/hooks/useApi'; +import { adminApi } from '@/features/admin/services/adminApi'; +import { AdminDataTable } from './AdminDataTable'; +import { CreateUserModal } from './CreateUserModal'; +import { ImpersonateUserModal } from './ImpersonateUserModal'; +import { Badge } from '@/shared/components/ui/Badge'; +import { Button } from '@/shared/components/ui/Button'; + +interface AdminUser { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; + status: 'active' | 'suspended' | 'pending'; + lastLoginAt: string | null; + createdAt: string; + account: { + id: string; + name: string; + }; +} + +export const UserManagement: React.FC = () => { + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + const [searchQuery, setSearchQuery] = useState(''); + const [filters, setFilters] = useState>({}); + const [sortColumn, setSortColumn] = useState('createdAt'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [showImpersonateModal, setShowImpersonateModal] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + + const { data: usersData, loading, execute: refetchUsers } = useApi( + () => adminApi.getUsers({ + page: currentPage, + per_page: itemsPerPage, + search: searchQuery, + filters, + sort: sortColumn, + direction: sortDirection + }), + { + immediate: true, + onError: (error) => console.error('Failed to load users:', error) + } + ); + + const columns = [ + { + key: 'email' as keyof AdminUser, + header: 'Email', + sortable: true, + render: (email: string, user: AdminUser) => ( +
+
{email}
+
+ {user.firstName} {user.lastName} +
+
+ ) + }, + { + key: 'account' as keyof AdminUser, + header: 'Account', + render: (account: AdminUser['account']) => ( +
{account.name}
+ ) + }, + { + key: 'role' as keyof AdminUser, + header: 'Role', + sortable: true, + render: (role: string) => ( + + {role.replace('_', ' ')} + + ) + }, + { + key: 'status' as keyof AdminUser, + header: 'Status', + sortable: true, + render: (status: string) => ( + + {status} + + ) + }, + { + key: 'lastLoginAt' as keyof AdminUser, + header: 'Last Login', + render: (lastLoginAt: string | null) => ( +
+ {lastLoginAt + ? new Date(lastLoginAt).toLocaleDateString() + : 'Never' + } +
+ ) + }, + { + key: 'actions' as keyof AdminUser, + header: 'Actions', + width: '200px', + render: (_, user: AdminUser) => ( +
+ + +
+ ) + } + ]; + + const bulkActions = [ + { label: 'Suspend Users', value: 'suspend', danger: true }, + { label: 'Activate Users', value: 'activate' }, + { label: 'Send Reset Email', value: 'reset_password' }, + { label: 'Delete Users', value: 'delete', danger: true } + ]; + + const filterOptions = [ + { + key: 'status', + label: 'Status', + type: 'select' as const, + options: [ + { label: 'Active', value: 'active' }, + { label: 'Suspended', value: 'suspended' }, + { label: 'Pending', value: 'pending' } + ] + }, + { + key: 'role', + label: 'Role', + type: 'select' as const, + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'Manager', value: 'manager' }, + { label: 'Member', value: 'member' } + ] + }, + { + key: 'created_after', + label: 'Created After', + type: 'date' as const + } + ]; + + const handleBulkAction = async (action: string, selectedIds: string[]) => { + try { + await adminApi.bulkUserAction(action, selectedIds); + refetchUsers(); + // Show success notification + } catch (error) { + console.error('Bulk action failed:', error); + // Show error notification + } + }; + + const handleExport = async (format: 'csv' | 'xlsx') => { + try { + const blob = await adminApi.exportUsers({ format, filters, search: searchQuery }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `users.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Export failed:', error); + } + }; + + const handleEditUser = (user: AdminUser) => { + // Implementation for editing user + console.log('Edit user:', user); + }; + + const getRoleVariant = (role: string) => { + const variants = { + admin: 'danger', + manager: 'warning', + member: 'default' + }; + return variants[role as keyof typeof variants] || 'default'; + }; + + const getStatusVariant = (status: string) => { + const variants = { + active: 'success', + suspended: 'danger', + pending: 'warning' + }; + return variants[status as keyof typeof variants] || 'default'; + }; + + return ( +
+ {/* Header */} +
+
+

User Management

+

+ Manage user accounts, roles, and permissions +

+
+ + +
+ + {/* Data table */} + { + setSortColumn(column); + setSortDirection(direction); + }} + onSearch={setSearchQuery} + onFilter={setFilters} + onBulkAction={handleBulkAction} + onExport={handleExport} + bulkActions={bulkActions} + filters={filterOptions} + searchPlaceholder="Search users by email, name, or account..." + /> + + {/* Modals */} + setShowCreateModal(false)} + onSuccess={refetchUsers} + /> + + {selectedUser && ( + { + setShowImpersonateModal(false); + setSelectedUser(null); + }} + user={selectedUser} + /> + )} +
+ ); +}; +``` + +### 3. System Monitoring Components (MANDATORY) + +#### System Health Dashboard +```tsx +// src/features/admin/components/SystemHealthDashboard.tsx +import React from 'react'; +import { useApi } from '@/shared/hooks/useApi'; +import { useInterval } from '@/shared/hooks/useInterval'; +import { adminApi } from '@/features/admin/services/adminApi'; +import { Card } from '@/shared/components/ui/Card'; +import { Badge } from '@/shared/components/ui/Badge'; +import { ProgressBar } from '@/shared/components/ui/ProgressBar'; + +interface SystemHealth { + status: 'healthy' | 'degraded' | 'critical'; + services: { + database: ServiceStatus; + redis: ServiceStatus; + sidekiq: ServiceStatus; + storage: ServiceStatus; + }; + metrics: { + cpuUsage: number; + memoryUsage: number; + diskUsage: number; + activeConnections: number; + responseTime: number; + }; + alerts: SystemAlert[]; +} + +interface ServiceStatus { + status: 'up' | 'down' | 'degraded'; + responseTime: number; + lastChecked: string; + error?: string; +} + +interface SystemAlert { + id: string; + level: 'info' | 'warning' | 'error' | 'critical'; + message: string; + timestamp: string; + resolved: boolean; +} + +export const SystemHealthDashboard: React.FC = () => { + const { data: healthData, loading, execute: refetchHealth } = useApi( + () => adminApi.getSystemHealth(), + { immediate: true } + ); + + // Refresh every 30 seconds + useInterval(() => { + refetchHealth(); + }, 30000); + + if (loading && !healthData) { + return
Loading system health...
; + } + + const health: SystemHealth = healthData || { + status: 'healthy', + services: { + database: { status: 'up', responseTime: 0, lastChecked: '' }, + redis: { status: 'up', responseTime: 0, lastChecked: '' }, + sidekiq: { status: 'up', responseTime: 0, lastChecked: '' }, + storage: { status: 'up', responseTime: 0, lastChecked: '' } + }, + metrics: { + cpuUsage: 0, + memoryUsage: 0, + diskUsage: 0, + activeConnections: 0, + responseTime: 0 + }, + alerts: [] + }; + + const getStatusVariant = (status: string) => { + const variants = { + healthy: 'success', + up: 'success', + degraded: 'warning', + critical: 'danger', + down: 'danger' + }; + return variants[status as keyof typeof variants] || 'default'; + }; + + const getAlertVariant = (level: string) => { + const variants = { + info: 'default', + warning: 'warning', + error: 'danger', + critical: 'danger' + }; + return variants[level as keyof typeof variants] || 'default'; + }; + + return ( +
+ {/* Overall Status */} + +
+
+

System Status

+

Overall system health and performance

+
+ + {health.status.toUpperCase()} + +
+
+ +
+ {/* Services Status */} + +

Services

+
+ {Object.entries(health.services).map(([serviceName, service]) => ( +
+
+
+
+
+ {serviceName} +
+ {service.error && ( +
{service.error}
+ )} +
+
+
+ + {service.status} + +
+ {service.responseTime}ms +
+
+
+ ))} +
+ + + {/* System Metrics */} + +

System Metrics

+
+
+
+ CPU Usage + {health.metrics.cpuUsage}% +
+ 80 ? 'danger' : health.metrics.cpuUsage > 60 ? 'warning' : 'success'} + /> +
+ +
+
+ Memory Usage + {health.metrics.memoryUsage}% +
+ 80 ? 'danger' : health.metrics.memoryUsage > 60 ? 'warning' : 'success'} + /> +
+ +
+
+ Disk Usage + {health.metrics.diskUsage}% +
+ 90 ? 'danger' : health.metrics.diskUsage > 75 ? 'warning' : 'success'} + /> +
+ +
+
+
+ {health.metrics.activeConnections} +
+
Active Connections
+
+
+
+ {health.metrics.responseTime}ms +
+
Avg Response Time
+
+
+
+
+
+ + {/* System Alerts */} + {health.alerts.length > 0 && ( + +

Recent Alerts

+
+ {health.alerts.slice(0, 10).map((alert) => ( +
+ + {alert.level} + +
+
+ {alert.message} +
+
+ {new Date(alert.timestamp).toLocaleString()} +
+
+ {alert.resolved && ( + Resolved + )} +
+ ))} +
+
+ )} +
+ ); +}; +``` + +## Development Commands + +### Admin Panel Development +```bash +# Install admin-specific dependencies +npm install @dnd-kit/core @dnd-kit/sortable react-window +npm install @tanstack/react-virtual + +# Run admin panel in development +npm start + +# Test admin components +npm test -- --testPathPattern=admin + +# Build optimized admin panel +npm run build +``` + +### Security and Permissions Testing +```bash +# Test permission-based access +npm run test:permissions + +# Security audit for admin functions +npm audit + +# Test bulk operations +npm run test:bulk-operations +``` + +## Integration Points + +### Admin Panel Developer Coordinates With: +- **Security Specialist**: Permission validation, audit logging +- **Backend Test Engineer**: Admin API endpoint testing +- **Data Modeler**: Complex data relationships for admin views +- **Performance Optimizer**: Large dataset handling, bulk operations +- **UI Component Developer**: Complex form components, data tables + +## Quick Reference + +### Admin Data Table Template +```tsx +const columns = [ + { key: 'name', header: 'Name', sortable: true }, + { key: 'status', header: 'Status', render: (status) => {status} }, + { key: 'actions', header: 'Actions', render: (_, row) => } +]; + + +``` + +### Permission Check Template +```tsx +const { hasPermission } = usePermissions(); + +if (!hasPermission('admin.users.manage')) { + return ; +} + +return ; +``` + +## Page Layout Validation Commands + +### Structure Compliance Audits +```bash +# Check for duplicate PageContainer usage in admin tab pages +grep -r "]*className=" src/features/admin/ | wc -l # Should be minimal +grep -r "> { + const response = await apiClient.get>(this.basePath); + return response.data; + } + + async getItem(id: string): Promise> { + const response = await apiClient.get>(`${this.basePath}/${id}`); + return response.data; + } + + async createItem(data: CreateItemRequest): Promise> { + const response = await apiClient.post>(this.basePath, data); + return response.data; + } + + async updateItem(id: string, data: UpdateItemRequest): Promise> { + const response = await apiClient.patch>(`${this.basePath}/${id}`, data); + return response.data; + } + + async deleteItem(id: string): Promise> { + const response = await apiClient.delete>(`${this.basePath}/${id}`); + return response.data; + } +} + +export const itemsApi = new ItemsApi(); +``` + +--- + +## API Client Pattern + +### Shared API Client + +**File**: `frontend/src/shared/services/apiClient.ts` + +```typescript +import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; + +const BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'; + +const createApiClient = (): AxiosInstance => { + const client = axios.create({ + baseURL: BASE_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + // Request interceptor - add auth token + client.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor - handle errors + client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }; + + // Handle 401 - token refresh + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('refresh_token'); + const response = await axios.post(`${BASE_URL}/auth/refresh`, { + refresh_token: refreshToken, + }); + + const { access_token, refresh_token } = response.data.data; + localStorage.setItem('access_token', access_token); + localStorage.setItem('refresh_token', refresh_token); + + // Retry original request + return client(originalRequest); + } catch (refreshError) { + // Refresh failed - redirect to login + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } + ); + + return client; +}; + +export const apiClient = createApiClient(); +``` + +### Request Configuration + +```typescript +// Custom request with options +const response = await apiClient.get('/endpoint', { + params: { page: 1, per_page: 20 }, + timeout: 60000, + headers: { 'X-Custom-Header': 'value' }, +}); + +// File upload +const formData = new FormData(); +formData.append('file', file); + +const response = await apiClient.post('/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (progressEvent) => { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total!); + setProgress(percentCompleted); + }, +}); +``` + +--- + +## Response Handling + +### Standard Response Format + +Backend returns consistent response format: + +```typescript +// Success response +interface ApiResponse { + success: true; + data: T; + message?: string; + meta?: { + pagination?: { + current_page: number; + total_pages: number; + total_count: number; + per_page: number; + }; + }; +} + +// Error response +interface ApiErrorResponse { + success: false; + error: string; + errors?: string[]; + details?: Record; +} +``` + +### Response Type Guards + +```typescript +export function isSuccessResponse( + response: ApiResponse | ApiErrorResponse +): response is ApiResponse { + return response.success === true; +} + +export function isErrorResponse( + response: ApiResponse | ApiErrorResponse +): response is ApiErrorResponse { + return response.success === false; +} +``` + +### Usage in Components + +```typescript +const loadItems = async () => { + try { + setLoading(true); + const response = await itemsApi.getItems(); + + if (response.success) { + setItems(response.data); + if (response.meta?.pagination) { + setPagination(response.meta.pagination); + } + } else { + showNotification(response.error || 'Failed to load items', 'error'); + } + } catch (error) { + showNotification(getErrorMessage(error), 'error'); + } finally { + setLoading(false); + } +}; +``` + +--- + +## Error Handling + +### Error Utilities + +**File**: `frontend/src/shared/utils/errorHandling.ts` + +```typescript +import { AxiosError } from 'axios'; + +interface ErrorWithResponse { + response?: { + data?: { + error?: string; + message?: string; + errors?: string[]; + }; + status?: number; + }; +} + +export function isErrorWithResponse(error: unknown): error is ErrorWithResponse { + return ( + typeof error === 'object' && + error !== null && + 'response' in error + ); +} + +export function getErrorMessage(error: unknown): string { + if (isErrorWithResponse(error)) { + const data = error.response?.data; + if (data?.error) return data.error; + if (data?.message) return data.message; + if (data?.errors?.length) return data.errors.join(', '); + } + + if (error instanceof Error) { + return error.message; + } + + return 'An unexpected error occurred'; +} + +export function getValidationErrors(error: unknown): Record { + if (isErrorWithResponse(error) && error.response?.data?.errors) { + // Handle Rails-style validation errors + return error.response.data.errors as Record; + } + return {}; +} + +export function isNetworkError(error: unknown): boolean { + return ( + error instanceof Error && + (error.message === 'Network Error' || error.message.includes('ECONNREFUSED')) + ); +} + +export function isTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + (error.message.includes('timeout') || error.message.includes('ETIMEDOUT')) + ); +} +``` + +### Error Handling in Services + +```typescript +class ItemsApi { + async getItems(): Promise | ApiErrorResponse> { + try { + const response = await apiClient.get>(this.basePath); + return response.data; + } catch (error) { + // Return error in consistent format + return { + success: false, + error: getErrorMessage(error), + }; + } + } +} +``` + +### Form Error Handling + +```typescript +const handleSubmit = async (values: FormValues) => { + try { + const response = await itemsApi.createItem(values); + + if (response.success) { + showNotification('Item created successfully', 'success'); + onSuccess(response.data); + } else { + // Show general error + setError(response.error); + } + } catch (error) { + if (isErrorWithResponse(error) && error.response?.status === 422) { + // Handle validation errors + const validationErrors = getValidationErrors(error); + Object.entries(validationErrors).forEach(([field, messages]) => { + setFieldError(field, messages[0]); + }); + } else { + setError(getErrorMessage(error)); + } + } +}; +``` + +--- + +## Authentication + +### Auth API Service + +**File**: `frontend/src/features/account/auth/services/authAPI.ts` + +```typescript +import { apiClient } from '@/shared/services/apiClient'; + +interface LoginRequest { + email: string; + password: string; +} + +interface LoginResponse { + success: boolean; + data: { + user: User; + access_token: string; + refresh_token: string; + }; +} + +interface RegisterRequest { + email: string; + password: string; + name: string; + account_name: string; + plan_id?: string; +} + +class AuthApi { + async login(credentials: LoginRequest) { + return apiClient.post('/auth/login', credentials); + } + + async register(userData: RegisterRequest) { + return apiClient.post('/auth/register', userData); + } + + async logout() { + return apiClient.post('/auth/logout'); + } + + async refreshToken(refreshToken: string) { + return apiClient.post('/auth/refresh', { refresh_token: refreshToken }); + } + + async getCurrentUser(silentAuth = false) { + return apiClient.get('/auth/me', { + headers: silentAuth ? { 'X-Silent-Auth': 'true' } : undefined, + }); + } + + async resendVerification() { + return apiClient.post('/auth/resend-verification'); + } + + async verify2FA(verificationToken: string, code: string) { + return apiClient.post('/auth/verify-2fa', { + verification_token: verificationToken, + code, + }); + } +} + +export const authApi = new AuthApi(); +``` + +### Impersonation API + +```typescript +class ImpersonationApi { + async startImpersonation(data: { user_id: string; reason?: string }) { + const response = await apiClient.post('/admin/impersonation/start', data); + return response.data; + } + + async stopImpersonation(sessionToken: string) { + const response = await apiClient.post('/admin/impersonation/stop', { + session_token: sessionToken, + }); + return response.data; + } + + async validateToken(token: string) { + const response = await apiClient.get('/admin/impersonation/validate', { + headers: { 'X-Impersonation-Token': token }, + }); + return response.data; + } +} + +export const impersonationApi = new ImpersonationApi(); +``` + +--- + +## Best Practices + +### 1. Type All Requests and Responses + +```typescript +// Good +interface CreateWorkflowRequest { + name: string; + description?: string; + nodes: WorkflowNode[]; +} + +async createWorkflow(data: CreateWorkflowRequest): Promise> { + // ... +} + +// Bad +async createWorkflow(data: any): Promise { + // ... +} +``` + +### 2. Use Consistent Base Paths + +```typescript +class MyApi { + private basePath = '/api/v1/my-resource'; + + // All methods use basePath + async getAll() { + return apiClient.get(this.basePath); + } + + async getOne(id: string) { + return apiClient.get(`${this.basePath}/${id}`); + } +} +``` + +### 3. Handle Loading States + +```typescript +const [loading, setLoading] = useState({ + list: false, + create: false, + update: {} as Record, + delete: {} as Record, +}); + +const createItem = async (data: CreateItemRequest) => { + setLoading(prev => ({ ...prev, create: true })); + try { + const response = await itemsApi.createItem(data); + // ... + } finally { + setLoading(prev => ({ ...prev, create: false })); + } +}; +``` + +### 4. Use Query Parameters Properly + +```typescript +interface ListParams { + page?: number; + per_page?: number; + search?: string; + status?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + +async getItems(params: ListParams = {}): Promise> { + const response = await apiClient.get(this.basePath, { params }); + return response.data; +} +``` + +### 5. Cache When Appropriate + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// Using React Query for caching +const useItems = () => { + return useQuery({ + queryKey: ['items'], + queryFn: () => itemsApi.getItems(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +const useCreateItem = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateItemRequest) => itemsApi.createItem(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['items'] }); + }, + }); +}; +``` + +### 6. Abort Requests on Unmount + +```typescript +useEffect(() => { + const controller = new AbortController(); + + const fetchData = async () => { + try { + const response = await apiClient.get('/items', { + signal: controller.signal, + }); + setData(response.data); + } catch (error) { + if (!axios.isCancel(error)) { + setError(getErrorMessage(error)); + } + } + }; + + fetchData(); + + return () => controller.abort(); +}, []); +``` + +--- + +## Testing + +### Mock API Client + +```typescript +// __mocks__/apiClient.ts +export const apiClient = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), +}; +``` + +### Service Testing + +```typescript +import { itemsApi } from './itemsApi'; +import { apiClient } from '@/shared/services/apiClient'; + +jest.mock('@/shared/services/apiClient'); + +describe('itemsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch items', async () => { + const mockItems = [{ id: '1', name: 'Item 1' }]; + (apiClient.get as jest.Mock).mockResolvedValue({ + data: { success: true, data: mockItems }, + }); + + const response = await itemsApi.getItems(); + + expect(apiClient.get).toHaveBeenCalledWith('/api/v1/items'); + expect(response.success).toBe(true); + expect(response.data).toEqual(mockItems); + }); + + it('should create item', async () => { + const newItem = { name: 'New Item' }; + const createdItem = { id: '2', name: 'New Item' }; + + (apiClient.post as jest.Mock).mockResolvedValue({ + data: { success: true, data: createdItem }, + }); + + const response = await itemsApi.createItem(newItem); + + expect(apiClient.post).toHaveBeenCalledWith('/api/v1/items', newItem); + expect(response.data).toEqual(createdItem); + }); +}); +``` + +--- + +**Document Status**: Complete +**Last Updated**: 2025-01-30 +**Source**: `frontend/src/features/*/services/` diff --git a/docs/frontend/CONTAINER_PATTERNS.md b/docs/frontend/CONTAINER_PATTERNS.md new file mode 100644 index 000000000..a5077274e --- /dev/null +++ b/docs/frontend/CONTAINER_PATTERNS.md @@ -0,0 +1,511 @@ +# Container Component Patterns +## PageContainer vs TabContainer Usage Guide + +--- + +## 📦 Component Hierarchy + +``` +PageContainer (Top Level - One per page) +├── Page Header (title, breadcrumbs, actions) +├── Page Description (optional) +└── Page Content + └── TabContainer (When tabs are needed) + ├── Tab Navigation + └── Tab Panels +``` + +--- + +## 🎯 PageContainer + +**Purpose**: Provides consistent page-level structure including header, breadcrumbs, and consolidated actions. + +**When to Use**: +- Every routable page component +- Top-level layout for any screen +- When you need breadcrumbs and page actions + +**Key Features**: +- Page title and description +- Breadcrumb navigation +- Consolidated action buttons +- Consistent padding and layout +- Permission-based action visibility + +### PageContainer Example + +```tsx +import { PageContainer, PageAction } from '@/shared/components/layout/PageContainer'; +import { Plus, RefreshCw, Download } from 'lucide-react'; + +export const UsersPage: React.FC = () => { + const getPageActions = (): PageAction[] => [ + { + id: 'create', + label: 'Create User', + onClick: handleCreate, + variant: 'primary', + icon: Plus, + permission: 'users.create' + }, + { + id: 'refresh', + label: 'Refresh', + onClick: handleRefresh, + variant: 'secondary', + icon: RefreshCw + }, + { + id: 'export', + label: 'Export', + onClick: handleExport, + variant: 'outline', + icon: Download + } + ]; + + const getBreadcrumbs = () => [ + { label: 'Dashboard', href: '/dashboard', icon: '🏠' }, + { label: 'Users', icon: '👥' } + ]; + + return ( + + {/* Page content goes here */} + + ); +}; +``` + +--- + +## 📑 TabContainer + +**Purpose**: Manages tabbed navigation within a page or section. + +**When to Use**: +- Multiple related views within a single page +- Settings pages with categories +- Multi-step forms or wizards +- Data views with different perspectives + +**Key Features**: +- Multiple tab variants (underline, pills, default) +- URL-based tab routing support +- Badge support for counts/status +- Responsive overflow handling +- Permission-based tab visibility + +### TabContainer Example + +```tsx +import { TabContainer, TabPanel } from '@/shared/components/layout/TabContainer'; +import { Users, Settings, Shield, Activity } from 'lucide-react'; + +export const AccountPage: React.FC = () => { + const [activeTab, setActiveTab] = useState('users'); + + const tabs = [ + { + id: 'users', + label: 'Users', + icon: , + path: '/users', + badge: { count: 12, variant: 'info' } + }, + { + id: 'teams', + label: 'Teams', + icon: '👥', + path: '/teams', + badge: { count: 3 } + }, + { + id: 'roles', + label: 'Roles & Permissions', + icon: , + path: '/roles' + }, + { + id: 'activity', + label: 'Activity', + icon: , + path: '/activity', + disabled: false + }, + { + id: 'settings', + label: 'Settings', + icon: , + path: '/settings', + permissions: ['account.settings.edit'] + } + ]; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; +``` + +--- + +## 🎨 TabContainer Variants + +### 1. Underline Tabs (Default) +```tsx + +``` +![Underline Tabs](underline-tabs.png) +- Clean, minimal design +- Clear active state with colored underline +- Best for main navigation within a page + +### 2. Pill Tabs +```tsx + +``` +![Pill Tabs](pill-tabs.png) +- Rounded, filled background for active state +- Good for settings or configuration pages +- Clear visual separation + +### 3. Default Tabs +```tsx + +``` +![Default Tabs](default-tabs.png) +- Subtle background change for active state +- Good for nested tab groups +- Less visual prominence + +--- + +## 🔄 URL-Based Tab Routing + +TabContainer supports automatic URL-based tab activation: + +```tsx + +``` + +This will automatically sync with routes: +- `/app/account` → Overview tab +- `/app/account/users` → Users tab +- `/app/account/settings` → Settings tab + +--- + +## ✅ Best Practices + +### DO's ✅ + +1. **Use PageContainer for every page** + ```tsx + // Every page component should have PageContainer + + {/* content */} + + ``` + +2. **Place TabContainer inside PageContainer** + ```tsx + + + {/* tabs */} + + + ``` + +3. **Use URL routing for main navigation tabs** + ```tsx + + ``` + +4. **Add badges for counts/status** + ```tsx + tabs={[ + { id: 'pending', label: 'Pending', badge: { count: 5, variant: 'warning' } } + ]} + ``` + +5. **Use icons consistently** + ```tsx + // Emojis for simple icons + { icon: '📊' } + + // Lucide icons for actions + { icon: } + ``` + +### DON'Ts ❌ + +1. **Don't nest PageContainers** + ```tsx + // ❌ WRONG + + + ``` + +2. **Don't put actions in TabContainer** + ```tsx + // ❌ WRONG - Actions belong in PageContainer + + + // ✅ CORRECT + + + ``` + +3. **Don't mix tab navigation patterns** + ```tsx + // ❌ WRONG - Multiple tab implementations + +
+ + ``` + +4. **Don't hardcode active tab styles** + ```tsx + // ❌ WRONG + className={activeTab === 'users' ? 'text-blue-500' : 'text-gray-500'} + + // ✅ CORRECT - Let TabContainer handle it + + ``` + +--- + +## 🔧 Migration Guide + +### Converting Custom Tabs to TabContainer + +**Before:** +```tsx +
+
+ {tabs.map(tab => ( + + ))} +
+
+{activeTab === 'users' && } +{activeTab === 'teams' && } +``` + +**After:** +```tsx + + + + + + + + +``` + +--- + +## 📊 Component Props Reference + +### PageContainer Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `title` | `string` | Yes | Page title | +| `description` | `string` | No | Page description | +| `breadcrumbs` | `Breadcrumb[]` | No | Breadcrumb navigation | +| `actions` | `PageAction[]` | No | Page-level actions | +| `children` | `ReactNode` | Yes | Page content | +| `className` | `string` | No | Additional CSS classes | + +### TabContainer Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `tabs` | `Tab[]` | Yes | - | Tab configuration | +| `activeTab` | `string` | No | First tab | Active tab ID | +| `onTabChange` | `(id: string) => void` | No | - | Tab change handler | +| `basePath` | `string` | No | - | Base URL for routing | +| `variant` | `'default' \| 'pills' \| 'underline'` | No | `'underline'` | Visual style | +| `size` | `'sm' \| 'md' \| 'lg'` | No | `'md'` | Tab size | +| `fullWidth` | `boolean` | No | `false` | Full width tabs | +| `renderContent` | `(activeTab: string) => ReactNode` | No | - | Dynamic content renderer | +| `children` | `ReactNode` | No | - | Static tab panels | + +### Tab Configuration + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `id` | `string` | Yes | Unique tab identifier | +| `label` | `string` | Yes | Tab label text | +| `icon` | `string \| ReactNode` | No | Tab icon (emoji or component) | +| `path` | `string` | No | URL path for routing | +| `badge` | `{ count: number, variant?: string }` | No | Badge configuration | +| `disabled` | `boolean` | No | Disable tab | +| `permissions` | `string[]` | No | Required permissions | + +--- + +## 🎯 Common Patterns + +### 1. Settings Page with Categories +```tsx + + + {/* Tab panels */} + + +``` + +### 2. Data Views with Filters +```tsx + + + {/* Different filtered views */} + + +``` + +### 3. Multi-Step Form +```tsx + + + {/* Form steps */} + + +``` + +--- + +## 🚀 Quick Reference + +```tsx +// Basic Setup +import { PageContainer } from '@/shared/components/layout/PageContainer'; +import { TabContainer, TabPanel } from '@/shared/components/layout/TabContainer'; + +// Page with Tabs + + + + {/* Content */} + + + + +// URL-based Tabs + + +// Dynamic Content + { + switch(activeTab) { + case 'tab1': return ; + case 'tab2': return ; + } + }} +/> +``` + +--- + +*Last Updated: [Current Date]* \ No newline at end of file diff --git a/docs/frontend/DASHBOARD_SPECIALIST.md b/docs/frontend/DASHBOARD_SPECIALIST.md new file mode 100644 index 000000000..400904bc8 --- /dev/null +++ b/docs/frontend/DASHBOARD_SPECIALIST.md @@ -0,0 +1,927 @@ +--- +Last Updated: 2026-02-28 +Platform Version: 0.3.1 +--- + +# Dashboard Specialist Guide + +## Role & Responsibilities + +The Dashboard Specialist specializes in analytics dashboards, interactive charts, and reporting interfaces for Powernode's subscription platform. + +### Core Responsibilities +- Implementing analytics dashboards +- Creating interactive charts and graphs +- Building reporting interfaces +- Handling real-time data updates +- Optimizing dashboard performance + +### Key Focus Areas +- Data visualization best practices +- Real-time data integration with WebSockets +- Interactive chart components and filtering +- KPI calculation and display +- Performance optimization for large datasets + +## Dashboard Architecture Standards + +### 1. Chart Library Integration (MANDATORY) + +#### Chart Component Architecture +```tsx +// src/shared/components/charts/BaseChart.tsx +import React, { useMemo, useCallback } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Title, + Tooltip, + Legend, + Filler +} from 'chart.js'; +import { Line, Bar, Doughnut, Pie } from 'react-chartjs-2'; +import { useTheme } from '@/shared/hooks/ThemeContext'; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Title, + Tooltip, + Legend, + Filler +); + +export interface BaseChartProps { + title?: string; + subtitle?: string; + data: any; + options?: any; + type: 'line' | 'bar' | 'doughnut' | 'pie'; + height?: number; + loading?: boolean; + error?: string; + className?: string; +} + +export const BaseChart: React.FC = ({ + title, + subtitle, + data, + options = {}, + type, + height = 400, + loading = false, + error, + className +}) => { + const { effectiveTheme } = useTheme(); + + // Theme-aware chart options + const chartOptions = useMemo(() => { + const isDark = effectiveTheme === 'dark'; + + const defaultOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { + color: isDark ? '#e5e7eb' : '#374151', + font: { + family: 'Inter, system-ui, sans-serif', + size: 12 + } + } + }, + tooltip: { + backgroundColor: isDark ? '#1f2937' : '#ffffff', + titleColor: isDark ? '#f9fafb' : '#111827', + bodyColor: isDark ? '#d1d5db' : '#374151', + borderColor: isDark ? '#374151' : '#e5e7eb', + borderWidth: 1 + } + }, + scales: type !== 'doughnut' && type !== 'pie' ? { + x: { + grid: { + color: isDark ? '#374151' : '#f3f4f6' + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280' + } + }, + y: { + grid: { + color: isDark ? '#374151' : '#f3f4f6' + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280' + } + } + } : undefined + }; + + return { + ...defaultOptions, + ...options + }; + }, [effectiveTheme, options, type]); + + const renderChart = useCallback(() => { + const ChartComponent = { + line: Line, + bar: Bar, + doughnut: Doughnut, + pie: Pie + }[type]; + + return ( + + ); + }, [type, data, chartOptions, height]); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load chart

+

{error}

+
+
+ ); + } + + return ( +
+ {(title || subtitle) && ( +
+ {title && ( +

{title}

+ )} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + +
+ {renderChart()} +
+
+ ); +}; +``` + +#### Specialized Chart Components +```tsx +// src/features/analytics/components/RevenueChart.tsx +import React, { useMemo } from 'react'; +import { BaseChart } from '@/shared/components/charts/BaseChart'; +import { formatCurrency } from '@/shared/utils/currency'; + +interface RevenueData { + date: string; + mrr: number; + arr: number; + newRevenue: number; + churnedRevenue: number; +} + +interface RevenueChartProps { + data: RevenueData[]; + timeRange: '30d' | '90d' | '12m'; + metric: 'mrr' | 'arr'; + loading?: boolean; + error?: string; +} + +export const RevenueChart: React.FC = ({ + data, + timeRange, + metric, + loading, + error +}) => { + const chartData = useMemo(() => { + const labels = data.map(d => { + const date = new Date(d.date); + return timeRange === '12m' + ? date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) + : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }); + + const revenueData = data.map(d => d[metric]); + const newRevenueData = data.map(d => d.newRevenue); + const churnedRevenueData = data.map(d => -d.churnedRevenue); + + return { + labels, + datasets: [ + { + label: metric.toUpperCase(), + data: revenueData, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }, + { + label: 'New Revenue', + data: newRevenueData, + borderColor: '#10b981', + backgroundColor: 'rgba(16, 185, 129, 0.1)', + tension: 0.4, + fill: false + }, + { + label: 'Churned Revenue', + data: churnedRevenueData, + borderColor: '#ef4444', + backgroundColor: 'rgba(239, 68, 68, 0.1)', + tension: 0.4, + fill: false + } + ] + }; + }, [data, metric, timeRange]); + + const chartOptions = useMemo(() => ({ + plugins: { + tooltip: { + callbacks: { + label: (context: any) => { + const value = Math.abs(context.parsed.y); + return `${context.dataset.label}: ${formatCurrency(value)}`; + } + } + } + }, + scales: { + y: { + ticks: { + callback: (value: any) => formatCurrency(Math.abs(value)) + } + } + } + }), []); + + const currentValue = data.length > 0 ? data[data.length - 1][metric] : 0; + const previousValue = data.length > 1 ? data[data.length - 2][metric] : currentValue; + const growthRate = previousValue !== 0 + ? ((currentValue - previousValue) / previousValue) * 100 + : 0; + + return ( +
+ {/* KPI Summary */} +
+
+
+ {formatCurrency(currentValue)} +
+
+ Current {metric.toUpperCase()} +
+
+ +
+
= 0 ? 'text-theme-success' : 'text-theme-error'}`}> + {growthRate >= 0 ? '+' : ''}{growthRate.toFixed(1)}% +
+
+ Growth Rate +
+
+ +
+
+ {formatCurrency(currentValue * 12)} +
+
+ Annualized +
+
+
+ + {/* Chart */} + +
+ ); +}; + +// src/features/analytics/components/ChurnChart.tsx +export const ChurnChart: React.FC<{ + data: Array<{ date: string; churnRate: number; cohortSize: number }>; + loading?: boolean; +}> = ({ data, loading }) => { + const chartData = useMemo(() => { + return { + labels: data.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short' })), + datasets: [ + { + label: 'Churn Rate (%)', + data: data.map(d => (d.churnRate * 100).toFixed(2)), + backgroundColor: 'rgba(239, 68, 68, 0.8)', + borderColor: '#ef4444', + borderWidth: 2 + } + ] + }; + }, [data]); + + const avgChurnRate = data.length > 0 + ? data.reduce((sum, d) => sum + d.churnRate, 0) / data.length + : 0; + + return ( +
+
+
+ {(avgChurnRate * 100).toFixed(2)}% +
+
+ Average Monthly Churn Rate +
+
+ + +
+ ); +}; +``` + +### 2. Real-Time Dashboard Integration (MANDATORY) + +#### WebSocket Integration for Live Data +```tsx +// src/features/analytics/hooks/useRealTimeMetrics.tsx +import { useState, useEffect, useRef } from 'react'; +import { useWebSocket } from '@/shared/hooks/useWebSocket'; + +interface MetricUpdate { + type: 'mrr' | 'arr' | 'active_subscriptions' | 'churn_rate'; + value: number; + timestamp: string; + change: number; +} + +interface RealTimeMetrics { + mrr: number; + arr: number; + activeSubscriptions: number; + churnRate: number; + lastUpdated: string; + isConnected: boolean; +} + +export const useRealTimeMetrics = (accountId: string) => { + const [metrics, setMetrics] = useState({ + mrr: 0, + arr: 0, + activeSubscriptions: 0, + churnRate: 0, + lastUpdated: new Date().toISOString(), + isConnected: false + }); + + const metricsRef = useRef(metrics); + metricsRef.current = metrics; + + const { isConnected, sendMessage } = useWebSocket({ + url: `${process.env.REACT_APP_WS_URL}/cable`, + protocols: ['actioncable-v1-json'], + onMessage: (data) => { + try { + const message = JSON.parse(data); + + if (message.type === 'ping') { + return; // Ignore ping messages + } + + if (message.type === 'metric_update') { + const update: MetricUpdate = message.data; + + setMetrics(prev => ({ + ...prev, + [update.type === 'active_subscriptions' ? 'activeSubscriptions' : update.type]: update.value, + lastUpdated: update.timestamp + })); + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }, + onConnect: () => { + // Subscribe to analytics channel + sendMessage(JSON.stringify({ + command: 'subscribe', + identifier: JSON.stringify({ + channel: 'AnalyticsChannel', + account_id: accountId + }) + })); + }, + reconnectAttempts: 5, + reconnectInterval: 3000 + }); + + useEffect(() => { + setMetrics(prev => ({ ...prev, isConnected })); + }, [isConnected]); + + return { + metrics, + isConnected, + refreshMetrics: () => { + sendMessage(JSON.stringify({ + command: 'message', + identifier: JSON.stringify({ + channel: 'AnalyticsChannel', + account_id: accountId + }), + data: JSON.stringify({ action: 'refresh_metrics' }) + })); + } + }; +}; + +// src/features/analytics/components/LiveMetricCard.tsx +interface LiveMetricCardProps { + title: string; + value: number; + previousValue?: number; + format?: 'currency' | 'number' | 'percentage'; + icon?: React.ReactNode; + isConnected?: boolean; + lastUpdated?: string; +} + +export const LiveMetricCard: React.FC = ({ + title, + value, + previousValue, + format = 'number', + icon, + isConnected = false, + lastUpdated +}) => { + const [isAnimating, setIsAnimating] = useState(false); + const prevValueRef = useRef(value); + + useEffect(() => { + if (prevValueRef.current !== value) { + setIsAnimating(true); + const timer = setTimeout(() => setIsAnimating(false), 500); + prevValueRef.current = value; + return () => clearTimeout(timer); + } + }, [value]); + + const formatValue = (val: number) => { + switch (format) { + case 'currency': + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(val); + case 'percentage': + return `${val.toFixed(2)}%`; + default: + return new Intl.NumberFormat().format(val); + } + }; + + const getChangeIndicator = () => { + if (previousValue === undefined || previousValue === value) return null; + + const change = value - previousValue; + const percentChange = previousValue !== 0 ? (change / previousValue) * 100 : 0; + const isPositive = change > 0; + + return ( +
+ {isPositive ? '↗' : '↘'} + {Math.abs(percentChange).toFixed(1)}% +
+ ); + }; + + return ( +
+ {/* Connection indicator */} +
+
+
+ + {/* Icon */} + {icon && ( +
+ {icon} +
+ )} + + {/* Title */} +

+ {title} +

+ + {/* Value */} +
+ {formatValue(value)} +
+ + {/* Change indicator */} +
+ {getChangeIndicator()} + + {lastUpdated && ( +
+ Updated {new Date(lastUpdated).toLocaleTimeString()} +
+ )} +
+ + {/* Loading animation overlay */} + {isAnimating && ( +
+ )} +
+ ); +}; +``` + +### 3. Dashboard Layout Components (MANDATORY) + +#### Dashboard Grid System +```tsx +// src/features/analytics/components/DashboardLayout.tsx +import React, { useState } from 'react'; +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToWindowEdges } from '@dnd-kit/modifiers'; + +interface DashboardWidget { + id: string; + title: string; + component: React.ComponentType; + props?: Record; + span?: { cols: number; rows: number }; + minSize?: { cols: number; rows: number }; +} + +interface DashboardLayoutProps { + widgets: DashboardWidget[]; + onWidgetOrderChange?: (widgets: DashboardWidget[]) => void; + editable?: boolean; + className?: string; +} + +export const DashboardLayout: React.FC = ({ + widgets: initialWidgets, + onWidgetOrderChange, + editable = false, + className +}) => { + const [widgets, setWidgets] = useState(initialWidgets); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: any) => { + const { active, over } = event; + + if (active.id !== over.id) { + const oldIndex = widgets.findIndex(widget => widget.id === active.id); + const newIndex = widgets.findIndex(widget => widget.id === over.id); + + const newWidgets = arrayMove(widgets, oldIndex, newIndex); + setWidgets(newWidgets); + onWidgetOrderChange?.(newWidgets); + } + }; + + return ( +
+ {editable ? ( + + w.id)} strategy={verticalListSortingStrategy}> +
+ {widgets.map((widget) => ( + + ))} +
+
+
+ ) : ( +
+ {widgets.map((widget) => ( + + ))} +
+ )} +
+ ); +}; + +// Individual dashboard widget component +const DashboardWidget: React.FC<{ widget: DashboardWidget }> = ({ widget }) => { + const { component: Component, props = {}, span = { cols: 6, rows: 1 } } = widget; + + const colSpan = Math.min(Math.max(span.cols, 1), 12); + const rowSpan = Math.max(span.rows, 1); + + return ( +
+ +
+ ); +}; + +// Sortable widget wrapper for drag & drop +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +const SortableWidget: React.FC<{ + widget: DashboardWidget; + editable: boolean; +}> = ({ widget, editable }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: widget.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ +
+ ); +}; +``` + +#### Performance Optimized Dashboard +```tsx +// src/features/analytics/components/PerformanceDashboard.tsx +import React, { memo, useMemo, useCallback } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useIntersectionObserver } from '@/shared/hooks/useIntersectionObserver'; + +interface PerformanceDashboardProps { + widgets: DashboardWidget[]; + containerHeight: number; +} + +export const PerformanceDashboard: React.FC = memo(({ + widgets, + containerHeight +}) => { + const parentRef = React.useRef(null); + + // Virtual scrolling for large numbers of widgets + const virtualizer = useVirtualizer({ + count: widgets.length, + getScrollElement: () => parentRef.current, + estimateSize: useCallback(() => 400, []), // Estimated widget height + overscan: 2 // Render 2 extra items outside viewport + }); + + const items = virtualizer.getVirtualItems(); + + return ( +
+
+ {items.map((virtualItem) => { + const widget = widgets[virtualItem.index]; + + return ( +
+ +
+ ); + })} +
+
+ ); +}); + +// Lazy-loaded widget component +const LazyWidget: React.FC<{ widget: DashboardWidget }> = ({ widget }) => { + const [ref, isIntersecting] = useIntersectionObserver({ + threshold: 0.1 + }); + + const { component: Component, props = {} } = widget; + + return ( +
+ {isIntersecting ? ( + + ) : ( +
+
+
+
+
+
+ )} +
+ ); +}; +``` + +## Development Commands + +### Dashboard Development +```bash +# Install chart.js and related dependencies +npm install chart.js react-chartjs-2 @dnd-kit/core @dnd-kit/sortable + +# Install virtualization for performance +npm install @tanstack/react-virtual + +# Install date handling for time series +npm install date-fns + +# Run dashboard in development +npm start + +# Test dashboard components +npm test -- --testPathPattern=analytics + +# Build optimized dashboard +npm run build +``` + +### Performance Testing +```bash +# Install performance testing tools +npm install --save-dev @testing-library/react-hooks +npm install --save-dev lighthouse + +# Run performance audits +npm run audit:performance + +# Analyze bundle size +npm run analyze +``` + +## Integration Points + +### Dashboard Specialist Coordinates With: +- **Analytics Engineer**: KPI calculations, data aggregation logic +- **React Architect**: Component architecture, state management patterns +- **UI Component Developer**: Chart component library, responsive design +- **Backend Test Engineer**: API data contracts, real-time data validation +- **Performance Optimizer**: Dashboard performance, data loading optimization + +## Quick Reference + +### Chart Component Template +```tsx +import React, { useMemo } from 'react'; +import { BaseChart } from '@/shared/components/charts/BaseChart'; + +export const CustomChart: React.FC<{ + data: ChartData[]; + loading?: boolean; +}> = ({ data, loading }) => { + const chartData = useMemo(() => ({ + labels: data.map(d => d.label), + datasets: [{ + label: 'Dataset', + data: data.map(d => d.value), + backgroundColor: 'rgba(59, 130, 246, 0.1)', + borderColor: '#3b82f6' + }] + }), [data]); + + return ( + + ); +}; +``` + +### Real-Time Metric Template +```tsx +const { metrics, isConnected } = useRealTimeMetrics(accountId); + +return ( + +); +``` diff --git a/docs/frontend/END_NODE_FEATURES.md b/docs/frontend/END_NODE_FEATURES.md new file mode 100644 index 000000000..f439a272c --- /dev/null +++ b/docs/frontend/END_NODE_FEATURES.md @@ -0,0 +1,118 @@ +# End Node Features - Workflow Designer + +## Overview +The End node is a control node that explicitly marks termination points in workflows. While workflows can terminate naturally, End nodes provide visual clarity and allow for different termination states. + +## Node Location in UI +- **Category**: Control +- **Icon**: Square (■) +- **Color**: Red theme (for visual distinction) +- **Position**: Found in the Node Palette under the Control category + +## Features + +### Visual Appearance +- Red header with "END" label +- Square icon indicating termination +- Status badge showing termination type (success/failure/etc.) +- Only accepts incoming connections (left handle) +- No outgoing connections (terminal node) + +### Configuration Options +The End node supports various termination configurations: + +```typescript +{ + end_trigger: 'success' | 'failure' | 'error', + success_message: string, // Optional success message + failure_message: string, // Optional failure message + deployment_approved: boolean, // Special deployment flag + artifacts: string[] // List of artifacts produced +} +``` + +### Termination Types +1. **Success End**: Normal successful completion + - Green success badge + - CheckCircle icon + - Optional success message + +2. **Failure End**: Error or failure termination + - Red danger badge + - XCircle icon + - Optional failure message + +3. **Custom End**: Any other termination state + - Outline badge + - Standard Square icon + +## Usage Examples + +### Simple Success Path +``` +Start → Process → Success End +``` + +### Conditional Branching +``` +Start → Condition → Success End + ↘ Failure End +``` + +### Multiple Exit Points +``` +Start → Validation → Quick Success End + ↘ Full Process → Complete End + ↘ Error Handler → Error End +``` + +## Workflow Validation + +### End Nodes Are Optional +- Workflows do **not** require End nodes +- If no End node exists, workflow terminates when all paths complete +- Warning (not error): "No explicit end node found - workflow will terminate when all paths complete" + +### Multiple End Nodes Allowed +- Workflows can have multiple End nodes +- Useful for different termination states +- Each branch can have its own End node + +## Implementation Details + +### Node Recognition +The End node is automatically recognized as an end node when: +- `node.type === 'end'` +- `node.data.isEndNode === true` +- `node.data.nodeType === 'end'` + +### Auto-flagging +When an End node is added to the workflow: +- `isEndNode` flag is automatically set to `true` +- Node is registered as a terminal point +- Validation system recognizes it as an end node + +### Component Location +- Component: `/frontend/src/shared/components/workflow/nodes/EndNode.tsx` +- Registration: `/frontend/src/shared/components/workflow/WorkflowBuilder.tsx` +- Palette Entry: `/frontend/src/shared/components/workflow/NodePalette.tsx` + +## Visual Indicators +- 📎 Artifacts indicator (when artifacts are configured) +- 🚀 Deployment approved indicator +- ⚙️ Settings icon (when node is selected) +- Color-coded badges for different states + +## Best Practices +1. Use End nodes to make termination points explicit +2. Add descriptive success/failure messages for clarity +3. Use different End nodes for different outcomes +4. Consider adding End nodes for error paths +5. Name End nodes descriptively (e.g., "Payment Success", "Validation Failed") + +## Testing +All End node functionality is covered by tests: +- `StartEndNodes.test.tsx`: End node recognition tests +- `workflowValidationService.test.ts`: Validation with/without End nodes +- Tests confirm multiple End nodes are allowed +- Tests verify optional End node behavior \ No newline at end of file diff --git a/docs/frontend/ENHANCED_COPY_BUTTON_FEATURE.md b/docs/frontend/ENHANCED_COPY_BUTTON_FEATURE.md new file mode 100644 index 000000000..3c4622fd2 --- /dev/null +++ b/docs/frontend/ENHANCED_COPY_BUTTON_FEATURE.md @@ -0,0 +1,140 @@ +# Enhanced Copy Button for Formatted Output - COMPLETE ✅ + +**Date**: 2025-10-15 +**Feature**: Intelligent copy button with format detection and multi-format support +**Status**: ✅ **IMPLEMENTED** + +--- + +## 🎯 Feature Summary + +Added an intelligent copy button system to the workflow execution details that automatically detects markdown-formatted content and provides multiple copy format options. + +## 🚀 Key Features + +### 1. **Automatic Format Detection** +- Detects markdown patterns in output (headers, bold, italic, links, code blocks, lists) +- Provides simple copy for non-formatted content +- Shows enhanced options menu for markdown content + +### 2. **Multi-Format Copy Options** + +**For Markdown Content**: +- **Markdown Format**: Copy raw markdown with all formatting preserved +- **Plain Text**: Strip markdown formatting, copy clean text +- **HTML Format**: Convert markdown to HTML for rich text pasting + +**For Non-Markdown Content**: +- **Simple Copy**: One-click clipboard copy with notification + +### 3. **Smart Output Extraction** +Automatically extracts text from various output formats: +- `output`, `result`, `data`, `response` fields +- `content`, `text`, `markdown`, `final_markdown` fields +- Nested object structures +- Raw strings and JSON + +## 📍 Implementation Locations + +### WorkflowExecutionDetails.tsx + +**Enhanced Copy Button Component** (lines 750-860): +- `EnhancedCopyButton` React component +- Detects markdown patterns +- Shows dropdown menu for format options +- Handles all copy operations + +**Helper Functions**: +- `isMarkdownContent()` (lines 713-729): Detects markdown patterns +- `extractOutputText()` (lines 731-748): Extracts text from various output structures +- `copyToClipboard()` (lines 704-711): Core clipboard operation with notifications + +**Integration Points**: +- Node input/output expandable content (lines 897, 902) +- JSON output rendering (lines 1142, 1146) +- Final workflow output (line 1664) + +## 🎨 User Experience + +### Simple Content +``` +[Copy Button] → Click → Content Copied ✓ +``` + +### Markdown Content +``` +[Copy Button ▼] → Click → Dropdown Menu + ├─ Markdown Format + ├─ Plain Text + └─ HTML Format +``` + +## 💡 Technical Insights + +`★ Insight ─────────────────────────────────────` +1. **Pattern-Based Detection**: Uses regex patterns to identify markdown syntax (headers, emphasis, links, code blocks, lists) for automatic format detection +2. **Recursive Output Extraction**: Traverses nested output structures to find actual text content regardless of API response format +3. **Format Conversion**: Provides on-the-fly markdown-to-plain-text and markdown-to-HTML conversion for maximum flexibility +`─────────────────────────────────────────────────` + +## 🔧 Usage Examples + +### For Markdown Formatter Output +When the markdown formatter node completes: +1. Click the copy button in the final output section +2. Dropdown menu appears with 3 format options +3. Select desired format (Markdown/Plain Text/HTML) +4. Content copied to clipboard with format-specific notification + +### For Standard Node Outputs +- Regular outputs show simple copy button +- Click to copy → notification appears +- No dropdown needed for non-formatted content + +## 📊 Benefits + +### User Benefits +- **Flexibility**: Copy in multiple formats depending on destination +- **Convenience**: One-click access to formatted/unformatted versions +- **Clarity**: Visual feedback via notifications confirms successful copy + +### Developer Benefits +- **Reusable Component**: `EnhancedCopyButton` can be used anywhere +- **Extensible**: Easy to add new format conversions +- **Type-Safe**: TypeScript integration ensures proper data handling + +## 🎯 Perfect For + +- ✅ Copying markdown blog posts from the Complete Blog Generation Workflow +- ✅ Extracting formatted content from AI agent outputs +- ✅ Sharing workflow results in different formats (Slack, email, documentation) +- ✅ Converting between markdown/plain text/HTML on the fly + +## 📁 Files Modified + +1. **`/frontend/src/features/ai-workflows/components/WorkflowExecutionDetails.tsx`** + - Added `EnhancedCopyButton` component + - Added `isMarkdownContent()` helper + - Added `extractOutputText()` helper + - Updated `copyToClipboard()` to accept format parameter + - Integrated enhanced button in 3 key locations + +## 🚀 Next Steps (Optional Enhancements) + +1. **Rich Text Preview**: Show formatted preview before copying +2. **Custom Formats**: Add support for CSV, XML, or other formats +3. **Keyboard Shortcuts**: Ctrl+Shift+C for quick copy +4. **Copy History**: Track recent copies for easy re-copying +5. **Format Templates**: Save custom format conversion templates + +--- + +**Feature Status**: ✅ **COMPLETE AND READY FOR USE** +**HMR Status**: Changes will be reflected immediately in development server +**Testing**: Ready for user testing with workflow execution outputs + +--- + +## 🎉 Summary + +The enhanced copy button automatically detects formatted content (especially markdown from our new markdown formatter node) and provides intelligent copy options. Users can now easily copy workflow outputs in multiple formats with a single click! diff --git a/docs/frontend/ESLINT_GUIDE.md b/docs/frontend/ESLINT_GUIDE.md new file mode 100644 index 000000000..bd2523939 --- /dev/null +++ b/docs/frontend/ESLINT_GUIDE.md @@ -0,0 +1,222 @@ +# ESLint Configuration Guide - Powernode Frontend + +## Overview + +The Powernode frontend uses a multi-tier ESLint configuration to balance security, code quality, and developer experience. + +## Configuration Files + +### 1. `.eslintrc.js` (Development) +- **Purpose**: Primary development configuration +- **Security**: Balanced approach with smart overrides +- **Usage**: `npm run lint` + +### 2. `.eslintrc.production.js` (Production) +- **Purpose**: Strict production checks +- **Security**: Maximum security enforcement +- **Usage**: `npm run lint:production` + +### 3. `.eslintrc.security.js` (Security Audit) +- **Purpose**: Security-focused linting +- **Security**: All security rules as errors +- **Usage**: `npm run lint:security` + +## NPM Scripts + +```bash +# Development linting (recommended for daily use) +npm run lint + +# Auto-fix development issues +npm run lint:fix + +# Security audit (CI/CD) +npm run lint:security + +# Production readiness check +npm run lint:production + +# Check ESLint configuration +npm run lint:check +``` + +## Security Rule Strategy + +### Object Injection (`security/detect-object-injection`) + +**Problem**: ESLint security plugin flags dynamic object property access as potential security issues. + +**Our Approach**: +- **Development**: Disabled globally (too many false positives) +- **Admin Components**: Disabled (authenticated, permission-controlled context) +- **UI Components**: Disabled (controlled design system props) +- **Public Components**: Enabled with explicit overrides +- **Production**: Warn level with required eslint-disable comments + +### False Positives in Admin Context + +These patterns are **safe** in authenticated admin interfaces: +```typescript +// Safe: Service status lookup with known keys +const service = healthStatus.services[serviceName]; + +// Safe: CSS class mapping with controlled props +const classes = themeClasses[variant]; + +// Safe: Form field access with validated keys +const value = formData[fieldName]; +``` + +### Dangerous Patterns (Always Avoided) + +```typescript +// Dangerous: User input directly accessing objects +const value = obj[userInput]; // ❌ Never do this + +// Dangerous: Dynamic code execution +eval(userCode); // ❌ Blocked by security rules + +// Dangerous: Prototype pollution +obj.__proto__ = malicious; // ❌ Prevented +``` + +## Rule Overrides by Context + +### Admin Components (`**/admin/**/*.tsx`) +```javascript +rules: { + 'security/detect-object-injection': 'off', // Safe in authenticated context + 'security/detect-possible-timing-attacks': 'off' // Admin operations authenticated +} +``` + +### UI Components (`**/shared/components/ui/**/*.tsx`) +```javascript +rules: { + 'security/detect-object-injection': 'off' // Design system prop access controlled +} +``` + +### Public Components (`**/public/**/*.tsx`) +```javascript +rules: { + 'security/detect-object-injection': 'error', // Strict security + 'security/detect-possible-timing-attacks': 'error' +} +``` + +### Test Files (`**/*.test.tsx`) +```javascript +rules: { + 'no-console': 'off', // Console allowed in tests + 'security/detect-object-injection': 'off' // Testing requires flexibility +} +``` + +## Troubleshooting Common Issues + +### 1. Build Failing with Security Warnings + +**Solution**: Use development ESLint config for builds: +```bash +# Instead of npm run build, use: +ESLINT_NO_DEV_ERRORS=true npm run build +``` + +### 2. Object Injection False Positives + +**Option A**: Use development config (recommended): +```bash +npm run lint # Uses .eslintrc.js with smart overrides +``` + +**Option B**: Explicit disable (production): +```typescript +// eslint-disable-next-line security/detect-object-injection +const value = safeObject[controlledKey]; +``` + +### 3. TypeScript Integration Issues + +Ensure TypeScript ESLint parser is used: +```javascript +parser: '@typescript-eslint/parser', +plugins: ['@typescript-eslint'] +``` + +### 4. CI/CD Integration + +Recommended CI/CD pipeline: +```yaml +# Development check (allows admin object access) +- run: npm run lint + +# Security audit (strict) +- run: npm run lint:security + +# Production readiness (strict) +- run: npm run lint:production +``` + +## Best Practices + +### 1. Development Workflow +- Use `npm run lint:fix` to auto-fix issues +- Run security audit before PR: `npm run lint:security` +- Check production readiness: `npm run lint:production` + +### 2. Security-Conscious Development +- Validate user input before object access +- Use TypeScript for type safety +- Prefer explicit property access over dynamic lookups +- Document security-critical code sections + +### 3. Admin Component Guidelines +- Object property access is allowed (authenticated context) +- Still validate external data sources +- Use TypeScript interfaces to constrain object shapes +- Log security-relevant operations + +### 4. Public Component Guidelines +- Follow strict security rules +- Validate all dynamic access +- Use allowlists for object property access +- Sanitize user inputs + +## Integration with IDE + +### VS Code Configuration (`.vscode/settings.json`) +```json +{ + "eslint.workingDirectories": ["frontend"], + "eslint.options": { + "configFile": "frontend/.eslintrc.js" + }, + "eslint.validate": [ + "javascript", + "typescript", + "javascriptreact", + "typescriptreact" + ] +} +``` + +### Enable ESLint Auto-fix on Save +```json +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } +} +``` + +## Conclusion + +This ESLint configuration provides: +- ✅ **Developer-friendly**: Smart overrides reduce false positives +- ✅ **Security-conscious**: Real security issues still caught +- ✅ **Context-aware**: Different rules for different component types +- ✅ **CI/CD ready**: Multiple configurations for different environments +- ✅ **Production-ready**: Strict checks available for production builds + +For questions or issues, check the configuration files and this guide for context-specific rule overrides. \ No newline at end of file diff --git a/docs/frontend/EXTERNAL_PROXY_QUICKSTART.md b/docs/frontend/EXTERNAL_PROXY_QUICKSTART.md new file mode 100644 index 000000000..d6374dac4 --- /dev/null +++ b/docs/frontend/EXTERNAL_PROXY_QUICKSTART.md @@ -0,0 +1,135 @@ +# External Reverse Proxy Quick Start + +## Your Setup +- **Frontend URL**: https://dev-1.ipnode.org/ +- **Backend API**: https://dev-1.ipnode.org/api/v1 +- **External nginx/Apache proxy** already configured + +## Quick Start (Recommended) + +Run the specialized external proxy script: + +```bash +cd frontend +./scripts/dev-external-proxy.sh +``` + +This script: +- Sets up the correct environment variables +- Configures Vite to connect HMR through your external proxy +- Starts the dev server on port 3001 accessible from your proxy + +## Manual Start + +If you prefer to start manually: + +```bash +cd frontend + +# Set environment variables +export VITE_BEHIND_PROXY=true +export VITE_PROXY_HOST=dev-1.ipnode.org +export VITE_PROXY_PROTOCOL=https + +# Use the external proxy configuration +npx vite --config vite.config.external-proxy.ts --host 0.0.0.0 +``` + +## What This Solves + +1. **HMR WebSocket Connection**: Forces Vite's HMR to connect through `wss://dev-1.ipnode.org` instead of `ws://localhost:3001` +2. **API Routing**: Frontend correctly uses `https://dev-1.ipnode.org/api/v1` for all API calls +3. **Asset Loading**: All assets load through the proxy URL + +## Verify It's Working + +1. **Check Console Output**: + You should see: + ``` + 🌐 Starting Vite for External Reverse Proxy + 📍 External URLs: + Frontend: https://dev-1.ipnode.org/ + Backend: https://dev-1.ipnode.org/api/v1 + ``` + +2. **Browser DevTools**: + - Open Network tab + - Look for WebSocket connection to `wss://dev-1.ipnode.org/@vite/hmr` + - Should show status 101 (Switching Protocols) + +3. **Test HMR**: + - Edit any React component + - Page should update without full reload + - Console should show: `[vite] hot updated` + +## Troubleshooting + +### Issue: Still connecting to localhost +**Solution**: Make sure you're using the external proxy config: +```bash +./scripts/dev-external-proxy.sh +# OR +npx vite --config vite.config.external-proxy.ts +``` + +### Issue: WebSocket fails to connect +**Solution**: Verify your external proxy forwards WebSocket headers: +- nginx must have: `proxy_set_header Upgrade $http_upgrade;` +- Apache must have: `RewriteCond %{HTTP:Upgrade} websocket [NC]` + +### Issue: API calls failing +**Solution**: Check that `VITE_API_BASE_URL` is set to `https://dev-1.ipnode.org/api/v1` + +## Environment Variables + +The external proxy configuration uses these settings: + +```env +# Critical settings +VITE_BEHIND_PROXY=true +VITE_PROXY_HOST=dev-1.ipnode.org +VITE_PROXY_PROTOCOL=https + +# API endpoints +VITE_API_BASE_URL=https://dev-1.ipnode.org/api/v1 +VITE_WS_BASE_URL=wss://dev-1.ipnode.org/cable + +# Server binding +HOST=0.0.0.0 # Bind to all interfaces +PORT=3001 # Local port your proxy connects to +``` + +## External Proxy Requirements + +Your external reverse proxy (nginx/Apache) must: + +1. **Forward to Vite dev server**: `http://your-server-ip:3001` +2. **Handle WebSocket upgrade** for paths: `/@vite/hmr`, `/@vite/client` +3. **Forward headers**: Host, X-Forwarded-Proto, X-Forwarded-Host +4. **Proxy API requests**: `/api/*` → Rails backend on port 3000 + +## Using Different Proxy Hosts + +To use a different external proxy host: + +1. Edit `vite.config.external-proxy.ts`: + ```typescript + hmr: { + host: 'your-proxy-domain.com', // Change this + // ... + } + ``` + +2. Update environment: + ```bash + export VITE_PROXY_HOST=your-proxy-domain.com + export VITE_API_BASE_URL=https://your-proxy-domain.com/api/v1 + ``` + +## Summary + +The key difference when using an external proxy: +- **Regular dev**: Vite serves directly, HMR connects to localhost +- **External proxy**: Vite serves to proxy, HMR connects through proxy domain + +Your setup with `dev-1.ipnode.org` requires the external proxy configuration to ensure all WebSocket and API connections go through the proxy URL, not localhost. \ No newline at end of file diff --git a/docs/frontend/FEATURE_DEVELOPMENT_GUIDE.md b/docs/frontend/FEATURE_DEVELOPMENT_GUIDE.md new file mode 100644 index 000000000..614f92283 --- /dev/null +++ b/docs/frontend/FEATURE_DEVELOPMENT_GUIDE.md @@ -0,0 +1,703 @@ +# Feature Development Guide + +**Standards and patterns for building features in Powernode frontend** + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Feature Structure](#feature-structure) +3. [Required Files](#required-files) +4. [Component Patterns](#component-patterns) +5. [Hook Patterns](#hook-patterns) +6. [Permission Integration](#permission-integration) +7. [Styling Guidelines](#styling-guidelines) +8. [Testing Requirements](#testing-requirements) + +--- + +## Overview + +Powernode frontend uses a feature-based architecture where each domain is self-contained with its own components, services, hooks, and types. + +### Feature Domains + +``` +frontend/src/features/ +├── account/ # User account, auth, profile +├── admin/ # System administration +├── ai/ # AI agents, workflows, monitoring +├── app/ # App shell, layout +├── baas/ # Billing-as-a-Service +├── business/ # Subscriptions, payments, invoices +├── content/ # CMS pages, blog, KB +├── delegations/ # Permission delegations +├── developer/ # API keys, webhooks, docs +├── devops/ # CI/CD, deployments +├── privacy/ # GDPR, consent management +├── supply-chain/ # Supply chain security +└── system/ # System settings, audit logs +``` + +--- + +## Feature Structure + +### Standard Feature Layout + +``` +frontend/src/features// +├── index.ts # Public exports +├── routes.tsx # Feature routes +│ +├── components/ # React components +│ ├── List.tsx # List view +│ ├── Form.tsx # Create/Edit form +│ ├── Detail.tsx # Detail view +│ └── Card.tsx # Card component +│ +├── pages/ # Page components +│ ├── ListPage.tsx # List page +│ ├── CreatePage.tsx # Create page +│ └── DetailPage.tsx # Detail page +│ +├── services/ # API services +│ └── Api.ts +│ +├── hooks/ # Custom hooks +│ └── use.ts +│ +└── types/ # TypeScript types + └── index.ts +``` + +### Example: AI Workflows Feature + +``` +frontend/src/features/ai/ +├── index.ts +├── routes.tsx +│ +├── workflows/ +│ ├── components/ +│ │ ├── WorkflowList.tsx +│ │ ├── WorkflowCard.tsx +│ │ ├── WorkflowBuilder.tsx +│ │ ├── WorkflowExecutionForm.tsx +│ │ └── WorkflowExecutionDetails.tsx +│ │ +│ ├── pages/ +│ │ ├── WorkflowsPage.tsx +│ │ ├── WorkflowCreatePage.tsx +│ │ └── WorkflowDetailPage.tsx +│ │ +│ ├── services/ +│ │ └── workflowsApi.ts +│ │ +│ └── hooks/ +│ ├── useWorkflows.ts +│ └── useWorkflowExecution.ts +│ +├── agents/ +│ └── ... +│ +└── monitoring/ + └── ... +``` + +--- + +## Required Files + +### Feature Index (index.ts) + +Export public API for the feature: + +```typescript +// frontend/src/features//index.ts + +// Components +export { DomainList } from './components/DomainList'; +export { DomainCard } from './components/DomainCard'; +export { DomainForm } from './components/DomainForm'; + +// Pages +export { DomainListPage } from './pages/DomainListPage'; +export { DomainDetailPage } from './pages/DomainDetailPage'; + +// Hooks +export { useDomain } from './hooks/useDomain'; + +// Types +export type { Domain, CreateDomainRequest, UpdateDomainRequest } from './types'; + +// API +export { domainApi } from './services/domainApi'; +``` + +### Feature Routes (routes.tsx) + +```typescript +// frontend/src/features//routes.tsx + +import { lazy } from 'react'; +import { RouteObject } from 'react-router-dom'; +import { PermissionGuard } from '@/shared/components/PermissionGuard'; + +const DomainListPage = lazy(() => import('./pages/DomainListPage')); +const DomainCreatePage = lazy(() => import('./pages/DomainCreatePage')); +const DomainDetailPage = lazy(() => import('./pages/DomainDetailPage')); + +export const domainRoutes: RouteObject[] = [ + { + path: 'domain', + children: [ + { + index: true, + element: ( + + + + ), + }, + { + path: 'new', + element: ( + + + + ), + }, + { + path: ':id', + element: ( + + + + ), + }, + ], + }, +]; +``` + +--- + +## Component Patterns + +### List Page Pattern + +```typescript +// pages/DomainListPage.tsx + +import { useState, useEffect } from 'react'; +import { PageContainer } from '@/shared/components/PageContainer'; +import { Button } from '@/shared/components/Button'; +import { DomainList } from '../components/DomainList'; +import { useDomain } from '../hooks/useDomain'; +import { usePermission } from '@/shared/hooks/usePermission'; + +export const DomainListPage = () => { + const { items, loading, error, refresh } = useDomain(); + const canCreate = usePermission('domain.create'); + + // Actions go in PageContainer + const actions = ( + <> + {canCreate && ( + + )} + + + ); + + return ( + + {error && } + + navigate(`/domain/${item.id}`)} + /> + + ); +}; +``` + +### Detail Page Pattern + +```typescript +// pages/DomainDetailPage.tsx + +import { useParams } from 'react-router-dom'; +import { PageContainer } from '@/shared/components/PageContainer'; +import { useDomainDetail } from '../hooks/useDomainDetail'; + +export const DomainDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const { item, loading, error, update, remove } = useDomainDetail(id!); + const canUpdate = usePermission('domain.update'); + const canDelete = usePermission('domain.delete'); + + if (loading) return ; + if (error) return ; + if (!item) return ; + + const actions = ( + <> + {canUpdate && ( + + )} + {canDelete && ( + + )} + + ); + + return ( + + + + ); +}; +``` + +### Form Component Pattern + +```typescript +// components/DomainForm.tsx + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; + +const schema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + description: z.string().optional(), + status: z.enum(['active', 'inactive']), +}); + +type FormValues = z.infer; + +interface DomainFormProps { + initialValues?: Partial; + onSubmit: (values: FormValues) => Promise; + onCancel: () => void; + isLoading?: boolean; +} + +export const DomainForm = ({ + initialValues, + onSubmit, + onCancel, + isLoading, +}: DomainFormProps) => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: initialValues, + }); + + return ( +
+
+ + + {errors.name && ( + {errors.name.message} + )} +
+ +
+ +