commit 4435e7ea0ad99cccb51db37e1ed5afcb80e0c853 Author: Benjamin Boenisch Date: Wed Feb 11 23:47:28 2026 +0100 Initial commit: breakpilot-compliance - Compliance SDK Platform Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..acc2a4b --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,51 @@ +# BreakPilot Compliance — DSGVO/AI-Act SDK Platform + +## Entwicklungsumgebung + +### Zwei-Rechner-Setup +| Gerät | Rolle | +|-------|-------| +| **MacBook** | Client/Terminal | +| **Mac Mini** | Server/Docker/Git | + +```bash +ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && " +``` + +## Voraussetzung +**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services (DB, Cache, Auth, RAG). + +## Projektübersicht + +**breakpilot-compliance** ist die Compliance-SDK-Plattform für DSGVO, AI Act und Datenschutz. + +### Enthaltene Services (~8 Container) + +| Service | Port | Beschreibung | +|---------|------|--------------| +| admin-compliance | 3007 | Compliance Admin (Next.js) | +| developer-portal | 3006 | API-Dokumentation | +| backend-compliance | 8002 | Compliance APIs (FastAPI) | +| ai-compliance-sdk | 8093 | KI-Compliance SDK | +| dsms-node | 4001 | IPFS Node | +| dsms-gateway | 8085 | IPFS Gateway | + +### Docker-Netzwerk +Nutzt das externe Core-Netzwerk: +```yaml +networks: + breakpilot-network: + external: true + name: breakpilot-network +``` + +### Container-Naming: `bp-compliance-*` +### DB search_path: `compliance,core,public` + +### SDK-Module (37 Routes) +TOM, DSFA, VVT, Löschfristen, AI-Act, Consent, DSR, Vendor Compliance, etc. + +## Git Remotes +Immer zu BEIDEN pushen: +- `origin`: lokale Gitea (macmini:3003) +- `gitea`: gitea.meghsakha.com diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..efba999 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# ========================================================= +# BreakPilot Compliance — Environment Variables +# ========================================================= +# Copy to .env and adjust values +# NOTE: Core must be running! These vars reference Core services. + +# Database (same as Core) +POSTGRES_USER=breakpilot +POSTGRES_PASSWORD=breakpilot123 +POSTGRES_DB=breakpilot_db + +# Security +JWT_SECRET=your-super-secret-jwt-key-change-in-production + +# Environment +ENVIRONMENT=development +TZ=Europe/Berlin + +# LLM Configuration +COMPLIANCE_LLM_PROVIDER=ollama +SELF_HOSTED_LLM_URL=http://host.docker.internal:11434 +SELF_HOSTED_LLM_MODEL=llama3.2 +COMPLIANCE_LLM_MAX_TOKENS=4096 +COMPLIANCE_LLM_TEMPERATURE=0.3 +COMPLIANCE_LLM_TIMEOUT=120 + +# Anthropic (optional fallback) +ANTHROPIC_API_KEY= +ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-5-20250929 + +# SDK +PII_REDACTION_ENABLED=true +PII_REDACTION_LEVEL=standard +AUDIT_RETENTION_DAYS=365 +AUDIT_LOG_PROMPTS=true + +# Frontend URLs +NEXT_PUBLIC_API_URL=https://macmini:8002 +NEXT_PUBLIC_SDK_URL=https://macmini:8093 + +# Session +SESSION_TTL_HOURS=24 + +# SMTP (uses Core Mailpit) +SMTP_HOST=bp-core-mailpit +SMTP_PORT=1025 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ca6b48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Environment +.env +.env.local +.env.backup + +# Secrets +secrets/ +*.pem +*.key + +# Node +node_modules/ +.next/ + +# Python +__pycache__/ +*.pyc +venv/ +.venv/ + +# Docker +backups/*.backup + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Logs +*.log + +# Large files +*.pdf +*.docx +*.xlsx +*.pptx +*.mp4 +*.mp3 +*.wav diff --git a/admin-compliance/.dockerignore b/admin-compliance/.dockerignore new file mode 100644 index 0000000..545b4b5 --- /dev/null +++ b/admin-compliance/.dockerignore @@ -0,0 +1,44 @@ +node_modules +.next +.git +.gitignore +README.md +*.log +.env.local +.env.*.local + +# Exclude stale root-level dirs that may appear inside admin-v2 +BreakpilotDrive +backend +docs +billing-service +consent-service +consent-sdk +ai-compliance-sdk +admin-v2 +edu-search-service +school-service +voice-service +geo-service +klausur-service +studio-v2 +website +scripts +agent-core +pca-platform +breakpilot-drive +breakpilot-compliance-sdk +dsms-gateway +dsms-node +h5p-service +ai-content-generator +policy_vault_* +docker +.docker +vault +librechat +nginx +e2e +vitest.config.ts +vitest.setup.ts +playwright.config.ts diff --git a/admin-compliance/.gitignore b/admin-compliance/.gitignore new file mode 100644 index 0000000..03bf87a --- /dev/null +++ b/admin-compliance/.gitignore @@ -0,0 +1,46 @@ +# dependencies +node_modules/ +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/admin-compliance/Dockerfile b/admin-compliance/Dockerfile new file mode 100644 index 0000000..6fb0577 --- /dev/null +++ b/admin-compliance/Dockerfile @@ -0,0 +1,57 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build arguments for environment variables +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_OLD_ADMIN_URL +ARG NEXT_PUBLIC_SDK_URL +ARG NEXT_PUBLIC_KLAUSUR_SERVICE_URL + +# Set environment variables for build +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL +ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL +ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Set to production +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Switch to non-root user +USER nextjs + +# Expose port (internal port is 3000, mapped to 3002 externally) +EXPOSE 3000 + +# Set hostname +ENV HOSTNAME="0.0.0.0" + +# Start the application +CMD ["node", "server.js"] diff --git a/admin-compliance/ai-compliance-sdk/Dockerfile b/admin-compliance/ai-compliance-sdk/Dockerfile new file mode 100644 index 0000000..cc3c2d7 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache git ca-certificates + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o sdk-backend ./cmd/server + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS +RUN apk add --no-cache ca-certificates tzdata + +# Copy binary from builder +COPY --from=builder /app/sdk-backend . +COPY --from=builder /app/configs ./configs + +# Create non-root user +RUN adduser -D -g '' appuser +USER appuser + +# Expose port +EXPOSE 8085 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8085/health || exit 1 + +# Run the application +CMD ["./sdk-backend"] diff --git a/admin-compliance/ai-compliance-sdk/cmd/server/main.go b/admin-compliance/ai-compliance-sdk/cmd/server/main.go new file mode 100644 index 0000000..15f3e1a --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/cmd/server/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/api" + "github.com/breakpilot/ai-compliance-sdk/internal/db" + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rag" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + // Load environment variables + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using environment variables") + } + + // Get configuration from environment + port := getEnv("PORT", "8085") + dbURL := getEnv("DATABASE_URL", "postgres://localhost:5432/sdk_states?sslmode=disable") + qdrantURL := getEnv("QDRANT_URL", "http://localhost:6333") + anthropicKey := getEnv("ANTHROPIC_API_KEY", "") + + // Initialize database connection + dbPool, err := db.NewPostgresPool(dbURL) + if err != nil { + log.Printf("Warning: Database connection failed: %v", err) + // Continue without database - use in-memory fallback + } + + // Initialize RAG service + ragService, err := rag.NewService(qdrantURL) + if err != nil { + log.Printf("Warning: RAG service initialization failed: %v", err) + // Continue without RAG - will return empty results + } + + // Initialize LLM service + llmService := llm.NewService(anthropicKey) + + // Create Gin router + gin.SetMode(gin.ReleaseMode) + if os.Getenv("GIN_MODE") == "debug" { + gin.SetMode(gin.DebugMode) + } + + router := gin.Default() + + // CORS middleware + router.Use(corsMiddleware()) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "services": gin.H{ + "database": dbPool != nil, + "rag": ragService != nil, + "llm": anthropicKey != "", + }, + }) + }) + + // API routes + v1 := router.Group("/sdk/v1") + { + // State Management + stateHandler := api.NewStateHandler(dbPool) + v1.GET("/state/:tenantId", stateHandler.GetState) + v1.POST("/state", stateHandler.SaveState) + v1.DELETE("/state/:tenantId", stateHandler.DeleteState) + + // RAG Search + ragHandler := api.NewRAGHandler(ragService) + v1.GET("/rag/search", ragHandler.Search) + v1.GET("/rag/status", ragHandler.GetCorpusStatus) + v1.POST("/rag/index", ragHandler.IndexDocument) + + // Document Generation + generateHandler := api.NewGenerateHandler(llmService, ragService) + v1.POST("/generate/dsfa", generateHandler.GenerateDSFA) + v1.POST("/generate/tom", generateHandler.GenerateTOM) + v1.POST("/generate/vvt", generateHandler.GenerateVVT) + v1.POST("/generate/gutachten", generateHandler.GenerateGutachten) + + // Checkpoint Validation + checkpointHandler := api.NewCheckpointHandler() + v1.GET("/checkpoints", checkpointHandler.GetAll) + v1.POST("/checkpoints/validate", checkpointHandler.Validate) + } + + // Create server + srv := &http.Server{ + Addr: ":" + port, + Handler: router, + } + + // Graceful shutdown + go func() { + log.Printf("SDK Backend starting on port %s", port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Give outstanding requests 5 seconds to complete + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + // Close database connection + if dbPool != nil { + dbPool.Close() + } + + log.Println("Server exited") +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, If-Match, If-None-Match") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag, Last-Modified") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} diff --git a/admin-compliance/ai-compliance-sdk/configs/config.yaml b/admin-compliance/ai-compliance-sdk/configs/config.yaml new file mode 100644 index 0000000..182fe2c --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/configs/config.yaml @@ -0,0 +1,42 @@ +server: + port: 8085 + mode: release # debug, release, test + +database: + url: postgres://localhost:5432/sdk_states?sslmode=disable + max_connections: 10 + min_connections: 2 + +rag: + qdrant_url: http://localhost:6333 + collection: legal_corpus + embedding_model: BGE-M3 + top_k: 5 + +llm: + provider: anthropic # anthropic, openai + model: claude-3-5-sonnet-20241022 + max_tokens: 4096 + temperature: 0.3 + +cors: + allowed_origins: + - http://localhost:3000 + - http://localhost:3002 + - http://macmini:3000 + - http://macmini:3002 + allowed_methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowed_headers: + - Content-Type + - Authorization + - If-Match + - If-None-Match + +logging: + level: info # debug, info, warn, error + format: json diff --git a/admin-compliance/ai-compliance-sdk/go.mod b/admin-compliance/ai-compliance-sdk/go.mod new file mode 100644 index 0000000..8a833e4 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/go.mod @@ -0,0 +1,11 @@ +module github.com/breakpilot/ai-compliance-sdk + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/jackc/pgx/v5 v5.5.1 + github.com/joho/godotenv v1.5.1 + github.com/qdrant/go-client v1.7.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/admin-compliance/ai-compliance-sdk/internal/api/checkpoint.go b/admin-compliance/ai-compliance-sdk/internal/api/checkpoint.go new file mode 100644 index 0000000..4652754 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/api/checkpoint.go @@ -0,0 +1,327 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Checkpoint represents a checkpoint definition +type Checkpoint struct { + ID string `json:"id"` + Step string `json:"step"` + Name string `json:"name"` + Type string `json:"type"` + BlocksProgress bool `json:"blocksProgress"` + RequiresReview string `json:"requiresReview"` + AutoValidate bool `json:"autoValidate"` + Description string `json:"description"` +} + +// CheckpointHandler handles checkpoint-related requests +type CheckpointHandler struct { + checkpoints map[string]Checkpoint +} + +// NewCheckpointHandler creates a new checkpoint handler +func NewCheckpointHandler() *CheckpointHandler { + return &CheckpointHandler{ + checkpoints: initCheckpoints(), + } +} + +func initCheckpoints() map[string]Checkpoint { + return map[string]Checkpoint{ + "CP-UC": { + ID: "CP-UC", + Step: "use-case-workshop", + Name: "Use Case Erfassung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Mindestens ein Use Case muss erfasst sein", + }, + "CP-SCAN": { + ID: "CP-SCAN", + Step: "screening", + Name: "System Screening", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "SBOM und Security Scan müssen abgeschlossen sein", + }, + "CP-MOD": { + ID: "CP-MOD", + Step: "modules", + Name: "Modul-Zuweisung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Mindestens ein Compliance-Modul muss zugewiesen sein", + }, + "CP-REQ": { + ID: "CP-REQ", + Step: "requirements", + Name: "Anforderungen", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Anforderungen müssen aus Regulierungen abgeleitet sein", + }, + "CP-CTRL": { + ID: "CP-CTRL", + Step: "controls", + Name: "Controls", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Controls müssen den Anforderungen zugeordnet sein", + }, + "CP-EVI": { + ID: "CP-EVI", + Step: "evidence", + Name: "Nachweise", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Nachweise für Controls müssen dokumentiert sein", + }, + "CP-CHK": { + ID: "CP-CHK", + Step: "audit-checklist", + Name: "Audit Checklist", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Prüfliste muss generiert und überprüft sein", + }, + "CP-RISK": { + ID: "CP-RISK", + Step: "risks", + Name: "Risikobewertung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Kritische Risiken müssen Mitigationsmaßnahmen haben", + }, + "CP-AI": { + ID: "CP-AI", + Step: "ai-act", + Name: "AI Act Klassifizierung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "LEGAL", + AutoValidate: false, + Description: "KI-System muss klassifiziert sein", + }, + "CP-OBL": { + ID: "CP-OBL", + Step: "obligations", + Name: "Pflichtenübersicht", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Rechtliche Pflichten müssen identifiziert sein", + }, + "CP-DSFA": { + ID: "CP-DSFA", + Step: "dsfa", + Name: "DSFA", + Type: "RECOMMENDED", + BlocksProgress: false, + RequiresReview: "DSB", + AutoValidate: false, + Description: "Datenschutz-Folgenabschätzung muss erstellt und genehmigt sein", + }, + "CP-TOM": { + ID: "CP-TOM", + Step: "tom", + Name: "TOMs", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Technische und organisatorische Maßnahmen müssen definiert sein", + }, + "CP-VVT": { + ID: "CP-VVT", + Step: "vvt", + Name: "Verarbeitungsverzeichnis", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "DSB", + AutoValidate: false, + Description: "Verarbeitungsverzeichnis muss vollständig sein", + }, + } +} + +// GetAll returns all checkpoint definitions +func (h *CheckpointHandler) GetAll(c *gin.Context) { + tenantID := c.Query("tenantId") + + checkpointList := make([]Checkpoint, 0, len(h.checkpoints)) + for _, cp := range h.checkpoints { + checkpointList = append(checkpointList, cp) + } + + SuccessResponse(c, gin.H{ + "tenantId": tenantID, + "checkpoints": checkpointList, + }) +} + +// Validate validates a specific checkpoint +func (h *CheckpointHandler) Validate(c *gin.Context) { + var req struct { + TenantID string `json:"tenantId" binding:"required"` + CheckpointID string `json:"checkpointId" binding:"required"` + Data map[string]interface{} `json:"data"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + checkpoint, ok := h.checkpoints[req.CheckpointID] + if !ok { + ErrorResponse(c, http.StatusNotFound, "Checkpoint not found", "CHECKPOINT_NOT_FOUND") + return + } + + // Perform validation based on checkpoint ID + result := h.validateCheckpoint(checkpoint, req.Data) + + SuccessResponse(c, result) +} + +func (h *CheckpointHandler) validateCheckpoint(checkpoint Checkpoint, data map[string]interface{}) CheckpointResult { + result := CheckpointResult{ + CheckpointID: checkpoint.ID, + Passed: true, + ValidatedAt: now(), + ValidatedBy: "SYSTEM", + Errors: []ValidationError{}, + Warnings: []ValidationError{}, + } + + // Validation logic based on checkpoint + switch checkpoint.ID { + case "CP-UC": + useCases, _ := data["useCases"].([]interface{}) + if len(useCases) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "uc-min-count", + Field: "useCases", + Message: "Mindestens ein Use Case muss erstellt werden", + Severity: "ERROR", + }) + } + + case "CP-SCAN": + screening, _ := data["screening"].(map[string]interface{}) + if screening == nil || screening["status"] != "COMPLETED" { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "scan-complete", + Field: "screening", + Message: "Security Scan muss abgeschlossen sein", + Severity: "ERROR", + }) + } + + case "CP-MOD": + modules, _ := data["modules"].([]interface{}) + if len(modules) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "mod-min-count", + Field: "modules", + Message: "Mindestens ein Modul muss zugewiesen werden", + Severity: "ERROR", + }) + } + + case "CP-RISK": + risks, _ := data["risks"].([]interface{}) + criticalUnmitigated := 0 + for _, r := range risks { + risk, ok := r.(map[string]interface{}) + if !ok { + continue + } + severity, _ := risk["severity"].(string) + if severity == "CRITICAL" || severity == "HIGH" { + mitigations, _ := risk["mitigation"].([]interface{}) + if len(mitigations) == 0 { + criticalUnmitigated++ + } + } + } + if criticalUnmitigated > 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "critical-risks-mitigated", + Field: "risks", + Message: "Kritische Risiken ohne Mitigationsmaßnahmen gefunden", + Severity: "ERROR", + }) + } + + case "CP-DSFA": + dsfa, _ := data["dsfa"].(map[string]interface{}) + if dsfa == nil { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "dsfa-exists", + Field: "dsfa", + Message: "DSFA muss erstellt werden", + Severity: "ERROR", + }) + } else if dsfa["status"] != "APPROVED" { + result.Warnings = append(result.Warnings, ValidationError{ + RuleID: "dsfa-approved", + Field: "dsfa", + Message: "DSFA sollte vom DSB genehmigt werden", + Severity: "WARNING", + }) + } + + case "CP-TOM": + toms, _ := data["toms"].([]interface{}) + if len(toms) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "tom-min-count", + Field: "toms", + Message: "Mindestens eine TOM muss definiert werden", + Severity: "ERROR", + }) + } + + case "CP-VVT": + vvt, _ := data["vvt"].([]interface{}) + if len(vvt) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "vvt-min-count", + Field: "vvt", + Message: "Mindestens eine Verarbeitungstätigkeit muss dokumentiert werden", + Severity: "ERROR", + }) + } + } + + return result +} diff --git a/admin-compliance/ai-compliance-sdk/internal/api/generate.go b/admin-compliance/ai-compliance-sdk/internal/api/generate.go new file mode 100644 index 0000000..7f7d8c9 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/api/generate.go @@ -0,0 +1,365 @@ +package api + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rag" + "github.com/gin-gonic/gin" +) + +// GenerateHandler handles document generation requests +type GenerateHandler struct { + llmService *llm.Service + ragService *rag.Service +} + +// NewGenerateHandler creates a new generate handler +func NewGenerateHandler(llmService *llm.Service, ragService *rag.Service) *GenerateHandler { + return &GenerateHandler{ + llmService: llmService, + ragService: ragService, + } +} + +// GenerateDSFA generates a Data Protection Impact Assessment +func (h *GenerateHandler) GenerateDSFA(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "DSFA Datenschutz-Folgenabschätzung Anforderungen" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate DSFA content + content, tokensUsed, err := h.llmService.GenerateDSFA(c.Request.Context(), req.Context, ragSources) + if err != nil { + // Return mock content if LLM fails + content = h.getMockDSFA(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.85, + }) +} + +// GenerateTOM generates Technical and Organizational Measures +func (h *GenerateHandler) GenerateTOM(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "technische organisatorische Maßnahmen TOM Datenschutz" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate TOM content + content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, ragSources) + if err != nil { + content = h.getMockTOM(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.82, + }) +} + +// GenerateVVT generates Processing Activity Register +func (h *GenerateHandler) GenerateVVT(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "Verarbeitungsverzeichnis Art. 30 DSGVO" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate VVT content + content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, ragSources) + if err != nil { + content = h.getMockVVT(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.88, + }) +} + +// GenerateGutachten generates an expert opinion/assessment +func (h *GenerateHandler) GenerateGutachten(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "Compliance Bewertung Gutachten" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate Gutachten content + content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, ragSources) + if err != nil { + content = h.getMockGutachten(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.80, + }) +} + +// Mock content generators for when LLM is not available +func (h *GenerateHandler) getMockDSFA(context map[string]interface{}) string { + return `# Datenschutz-Folgenabschätzung (DSFA) + +## 1. Systematische Beschreibung der Verarbeitungsvorgänge + +Die geplante Verarbeitung umfasst die Analyse von Kundendaten mittels KI-gestützter Systeme zur Verbesserung der Servicequalität und Personalisierung von Angeboten. + +### Verarbeitungszwecke: +- Kundensegmentierung und Analyse des Nutzerverhaltens +- Personalisierte Empfehlungen +- Optimierung von Geschäftsprozessen + +### Rechtsgrundlage: +- Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) +- Alternativ: Art. 6 Abs. 1 lit. a DSGVO (Einwilligung) + +## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit + +Die Verarbeitung ist für die genannten Zwecke erforderlich und verhältnismäßig. Alternative Maßnahmen wurden geprüft, jedoch sind diese weniger effektiv. + +## 3. Risikobewertung + +### Identifizierte Risiken: +| Risiko | Eintrittswahrscheinlichkeit | Schwere | Maßnahmen | +|--------|---------------------------|---------|-----------| +| Unbefugter Zugriff | Mittel | Hoch | Verschlüsselung, Zugangskontrolle | +| Profilbildung | Hoch | Mittel | Anonymisierung, Einwilligung | +| Datenverlust | Niedrig | Hoch | Backup, Redundanz | + +## 4. Maßnahmen zur Risikominderung + +- Implementierung von Verschlüsselung (AES-256) +- Strenge Zugriffskontrollen nach dem Least-Privilege-Prinzip +- Regelmäßige Datenschutz-Schulungen +- Audit-Logging aller Zugriffe + +## 5. Stellungnahme des Datenschutzbeauftragten + +[Hier Stellungnahme einfügen] + +## 6. Dokumentation der Konsultation + +Erstellt am: ${new Date().toISOString()} +Status: ENTWURF +` +} + +func (h *GenerateHandler) getMockTOM(context map[string]interface{}) string { + return `# Technische und Organisatorische Maßnahmen (TOMs) + +## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO) + +### 1.1 Zutrittskontrolle +- Alarmanlage +- Chipkarten-/Transponder-System +- Videoüberwachung der Eingänge +- Besuchererfassung und -begleitung + +### 1.2 Zugangskontrolle +- Passwort-Richtlinie (min. 12 Zeichen, Komplexitätsanforderungen) +- Multi-Faktor-Authentifizierung +- Automatische Bildschirmsperre +- VPN für Remote-Zugriffe + +### 1.3 Zugriffskontrolle +- Rollenbasiertes Berechtigungskonzept +- Need-to-know-Prinzip +- Regelmäßige Überprüfung der Zugriffsrechte +- Protokollierung aller Zugriffe + +## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO) + +### 2.1 Weitergabekontrolle +- Transportverschlüsselung (TLS 1.3) +- Ende-zu-Ende-Verschlüsselung für sensible Daten +- Sichere E-Mail-Kommunikation (S/MIME) + +### 2.2 Eingabekontrolle +- Protokollierung aller Datenänderungen +- Benutzeridentifikation bei Änderungen +- Audit-Trail für alle Transaktionen + +## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO) + +### 3.1 Verfügbarkeitskontrolle +- Tägliche Backups +- Georedundante Datenspeicherung +- USV-Anlage +- Notfallplan + +### 3.2 Wiederherstellung +- Dokumentierte Wiederherstellungsverfahren +- Regelmäßige Backup-Tests +- Maximale Wiederherstellungszeit: 4 Stunden + +## 4. Belastbarkeit (Art. 32 Abs. 1 lit. b DSGVO) + +- Lastverteilung +- DDoS-Schutz +- Skalierbare Infrastruktur +` +} + +func (h *GenerateHandler) getMockVVT(context map[string]interface{}) string { + return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO) + +## Verarbeitungstätigkeit: Kundenanalyse und Personalisierung + +### Angaben nach Art. 30 Abs. 1 DSGVO: + +| Feld | Inhalt | +|------|--------| +| **Name des Verantwortlichen** | [Unternehmensname] | +| **Kontaktdaten** | [Adresse, E-Mail, Telefon] | +| **Datenschutzbeauftragter** | [Name, Kontakt] | +| **Zweck der Verarbeitung** | Kundensegmentierung, Personalisierung, Serviceoptimierung | +| **Kategorien betroffener Personen** | Kunden, Interessenten | +| **Kategorien personenbezogener Daten** | Kontaktdaten, Nutzungsdaten, Transaktionsdaten | +| **Kategorien von Empfängern** | Interne Abteilungen, IT-Dienstleister | +| **Drittlandtransfer** | Nein / Ja (mit Angabe der Garantien) | +| **Löschfristen** | 3 Jahre nach letzter Aktivität | +| **TOM-Referenz** | Siehe TOM-Dokument v1.0 | + +### Rechtsgrundlage: +Art. 6 Abs. 1 lit. f DSGVO - Berechtigtes Interesse + +### Dokumentation: +- Erstellt: ${new Date().toISOString()} +- Letzte Aktualisierung: ${new Date().toISOString()} +- Version: 1.0 +` +} + +func (h *GenerateHandler) getMockGutachten(context map[string]interface{}) string { + return `# Compliance-Gutachten + +## Zusammenfassung + +Das geprüfte KI-System erfüllt die wesentlichen Anforderungen der DSGVO und des AI Acts. Es wurden jedoch Optimierungspotenziale identifiziert. + +## Prüfungsumfang + +- DSGVO-Konformität +- AI Act Compliance +- NIS2-Anforderungen + +## Bewertungsergebnis + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Datenschutz | Gut | Gering | +| KI-Risikoeinstufung | Erfüllt | Keiner | +| Cybersicherheit | Befriedigend | Mittel | + +## Empfehlungen + +1. Verstärkung der Dokumentation +2. Regelmäßige Audits einplanen +3. Schulungsmaßnahmen erweitern + +Erstellt am: ${new Date().toISOString()} +` +} diff --git a/admin-compliance/ai-compliance-sdk/internal/api/rag.go b/admin-compliance/ai-compliance-sdk/internal/api/rag.go new file mode 100644 index 0000000..286a888 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/api/rag.go @@ -0,0 +1,182 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/breakpilot/ai-compliance-sdk/internal/rag" + "github.com/gin-gonic/gin" +) + +// RAGHandler handles RAG search requests +type RAGHandler struct { + ragService *rag.Service +} + +// NewRAGHandler creates a new RAG handler +func NewRAGHandler(ragService *rag.Service) *RAGHandler { + return &RAGHandler{ + ragService: ragService, + } +} + +// Search performs semantic search on the legal corpus +func (h *RAGHandler) Search(c *gin.Context) { + query := c.Query("q") + if query == "" { + ErrorResponse(c, http.StatusBadRequest, "Query parameter 'q' is required", "MISSING_QUERY") + return + } + + topK := 5 + if topKStr := c.Query("top_k"); topKStr != "" { + if parsed, err := strconv.Atoi(topKStr); err == nil && parsed > 0 { + topK = parsed + } + } + + collection := c.DefaultQuery("collection", "legal_corpus") + filter := c.Query("filter") // e.g., "regulation:DSGVO" or "category:ai_act" + + // Check if RAG service is available + if h.ragService == nil { + // Return mock data when RAG is not available + SuccessResponse(c, gin.H{ + "query": query, + "topK": topK, + "results": h.getMockResults(query), + "source": "mock", + }) + return + } + + results, err := h.ragService.Search(c.Request.Context(), query, topK, collection, filter) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Search failed: "+err.Error(), "SEARCH_FAILED") + return + } + + SuccessResponse(c, gin.H{ + "query": query, + "topK": topK, + "results": results, + "source": "qdrant", + }) +} + +// GetCorpusStatus returns the status of the legal corpus +func (h *RAGHandler) GetCorpusStatus(c *gin.Context) { + if h.ragService == nil { + SuccessResponse(c, gin.H{ + "status": "unavailable", + "collections": []string{}, + "documents": 0, + }) + return + } + + status, err := h.ragService.GetCorpusStatus(c.Request.Context()) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to get corpus status", "STATUS_FAILED") + return + } + + SuccessResponse(c, status) +} + +// IndexDocument indexes a new document into the corpus +func (h *RAGHandler) IndexDocument(c *gin.Context) { + var req struct { + Collection string `json:"collection" binding:"required"` + ID string `json:"id" binding:"required"` + Content string `json:"content" binding:"required"` + Metadata map[string]string `json:"metadata"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + if h.ragService == nil { + ErrorResponse(c, http.StatusServiceUnavailable, "RAG service not available", "SERVICE_UNAVAILABLE") + return + } + + err := h.ragService.IndexDocument(c.Request.Context(), req.Collection, req.ID, req.Content, req.Metadata) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to index document: "+err.Error(), "INDEX_FAILED") + return + } + + SuccessResponse(c, gin.H{ + "indexed": true, + "id": req.ID, + "collection": req.Collection, + "indexedAt": now(), + }) +} + +// getMockResults returns mock search results for development +func (h *RAGHandler) getMockResults(query string) []SearchResult { + // Simplified mock results based on common compliance queries + results := []SearchResult{ + { + ID: "dsgvo-art-5", + Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten: Personenbezogene Daten müssen auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden.", + Source: "DSGVO", + Score: 0.95, + Metadata: map[string]string{ + "article": "5", + "regulation": "DSGVO", + "category": "grundsaetze", + }, + }, + { + ID: "dsgvo-art-6", + Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung: Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der folgenden Bedingungen erfüllt ist: Einwilligung, Vertragserfüllung, rechtliche Verpflichtung, lebenswichtige Interessen, öffentliche Aufgabe, berechtigtes Interesse.", + Source: "DSGVO", + Score: 0.89, + Metadata: map[string]string{ + "article": "6", + "regulation": "DSGVO", + "category": "rechtsgrundlage", + }, + }, + { + ID: "ai-act-art-6", + Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme: Ein KI-System gilt als Hochrisiko-System, wenn es als Sicherheitskomponente eines Produkts verwendet wird oder selbst ein Produkt ist, das unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften fällt.", + Source: "AI Act", + Score: 0.85, + Metadata: map[string]string{ + "article": "6", + "regulation": "AI_ACT", + "category": "hochrisiko", + }, + }, + { + ID: "nis2-art-21", + Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen: Wesentliche und wichtige Einrichtungen müssen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme zu beherrschen.", + Source: "NIS2", + Score: 0.78, + Metadata: map[string]string{ + "article": "21", + "regulation": "NIS2", + "category": "risikomanagement", + }, + }, + { + ID: "dsgvo-art-35", + Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung: Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.", + Source: "DSGVO", + Score: 0.75, + Metadata: map[string]string{ + "article": "35", + "regulation": "DSGVO", + "category": "dsfa", + }, + }, + } + + return results +} diff --git a/admin-compliance/ai-compliance-sdk/internal/api/router.go b/admin-compliance/ai-compliance-sdk/internal/api/router.go new file mode 100644 index 0000000..eb2db0e --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/api/router.go @@ -0,0 +1,96 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// Response represents a standard API response +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Code string `json:"code,omitempty"` +} + +// SuccessResponse creates a success response +func SuccessResponse(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + }) +} + +// ErrorResponse creates an error response +func ErrorResponse(c *gin.Context, status int, err string, code string) { + c.JSON(status, Response{ + Success: false, + Error: err, + Code: code, + }) +} + +// StateData represents state response data +type StateData struct { + TenantID string `json:"tenantId"` + State interface{} `json:"state"` + Version int `json:"version"` + LastModified string `json:"lastModified"` +} + +// ValidationError represents a validation error +type ValidationError struct { + RuleID string `json:"ruleId"` + Field string `json:"field"` + Message string `json:"message"` + Severity string `json:"severity"` +} + +// CheckpointResult represents checkpoint validation result +type CheckpointResult struct { + CheckpointID string `json:"checkpointId"` + Passed bool `json:"passed"` + ValidatedAt string `json:"validatedAt"` + ValidatedBy string `json:"validatedBy"` + Errors []ValidationError `json:"errors"` + Warnings []ValidationError `json:"warnings"` +} + +// SearchResult represents a RAG search result +type SearchResult struct { + ID string `json:"id"` + Content string `json:"content"` + Source string `json:"source"` + Score float64 `json:"score"` + Metadata map[string]string `json:"metadata,omitempty"` + Highlights []string `json:"highlights,omitempty"` +} + +// GenerateRequest represents a document generation request +type GenerateRequest struct { + TenantID string `json:"tenantId" binding:"required"` + Context map[string]interface{} `json:"context"` + Template string `json:"template,omitempty"` + Language string `json:"language,omitempty"` + UseRAG bool `json:"useRag"` + RAGQuery string `json:"ragQuery,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +// GenerateResponse represents a document generation response +type GenerateResponse struct { + Content string `json:"content"` + GeneratedAt string `json:"generatedAt"` + Model string `json:"model"` + TokensUsed int `json:"tokensUsed"` + RAGSources []SearchResult `json:"ragSources,omitempty"` + Confidence float64 `json:"confidence,omitempty"` +} + +// Timestamps helper +func now() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/admin-compliance/ai-compliance-sdk/internal/api/state.go b/admin-compliance/ai-compliance-sdk/internal/api/state.go new file mode 100644 index 0000000..2980d98 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/api/state.go @@ -0,0 +1,171 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/breakpilot/ai-compliance-sdk/internal/db" + "github.com/gin-gonic/gin" +) + +// StateHandler handles state management requests +type StateHandler struct { + dbPool *db.Pool + memStore *db.InMemoryStore +} + +// NewStateHandler creates a new state handler +func NewStateHandler(dbPool *db.Pool) *StateHandler { + return &StateHandler{ + dbPool: dbPool, + memStore: db.NewInMemoryStore(), + } +} + +// GetState retrieves state for a tenant +func (h *StateHandler) GetState(c *gin.Context) { + tenantID := c.Param("tenantId") + if tenantID == "" { + ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID") + return + } + + var state *db.SDKState + var err error + + // Try database first, fall back to in-memory + if h.dbPool != nil { + state, err = h.dbPool.GetState(c.Request.Context(), tenantID) + } else { + state, err = h.memStore.GetState(tenantID) + } + + if err != nil { + ErrorResponse(c, http.StatusNotFound, "State not found", "STATE_NOT_FOUND") + return + } + + // Generate ETag + etag := generateETag(state.Version, state.UpdatedAt.String()) + + // Check If-None-Match header + if c.GetHeader("If-None-Match") == etag { + c.Status(http.StatusNotModified) + return + } + + // Parse state JSON + var stateData interface{} + if err := json.Unmarshal(state.State, &stateData); err != nil { + stateData = state.State + } + + c.Header("ETag", etag) + c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT")) + c.Header("Cache-Control", "private, no-cache") + + SuccessResponse(c, StateData{ + TenantID: state.TenantID, + State: stateData, + Version: state.Version, + LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) +} + +// SaveState saves state for a tenant +func (h *StateHandler) SaveState(c *gin.Context) { + var req struct { + TenantID string `json:"tenantId" binding:"required"` + UserID string `json:"userId"` + State json.RawMessage `json:"state" binding:"required"` + Version *int `json:"version"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Check If-Match header for optimistic locking + var expectedVersion *int + if ifMatch := c.GetHeader("If-Match"); ifMatch != "" { + v, err := strconv.Atoi(ifMatch) + if err == nil { + expectedVersion = &v + } + } else if req.Version != nil { + expectedVersion = req.Version + } + + var state *db.SDKState + var err error + + // Try database first, fall back to in-memory + if h.dbPool != nil { + state, err = h.dbPool.SaveState(c.Request.Context(), req.TenantID, req.UserID, req.State, expectedVersion) + } else { + state, err = h.memStore.SaveState(req.TenantID, req.UserID, req.State, expectedVersion) + } + + if err != nil { + if err.Error() == "version conflict" { + ErrorResponse(c, http.StatusConflict, "Version conflict. State was modified by another request.", "VERSION_CONFLICT") + return + } + ErrorResponse(c, http.StatusInternalServerError, "Failed to save state", "SAVE_FAILED") + return + } + + // Generate ETag + etag := generateETag(state.Version, state.UpdatedAt.String()) + + // Parse state JSON + var stateData interface{} + if err := json.Unmarshal(state.State, &stateData); err != nil { + stateData = state.State + } + + c.Header("ETag", etag) + c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT")) + + SuccessResponse(c, StateData{ + TenantID: state.TenantID, + State: stateData, + Version: state.Version, + LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) +} + +// DeleteState deletes state for a tenant +func (h *StateHandler) DeleteState(c *gin.Context) { + tenantID := c.Param("tenantId") + if tenantID == "" { + ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID") + return + } + + var err error + + // Try database first, fall back to in-memory + if h.dbPool != nil { + err = h.dbPool.DeleteState(c.Request.Context(), tenantID) + } else { + err = h.memStore.DeleteState(tenantID) + } + + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to delete state", "DELETE_FAILED") + return + } + + SuccessResponse(c, gin.H{ + "tenantId": tenantID, + "deletedAt": now(), + }) +} + +// generateETag creates an ETag from version and timestamp +func generateETag(version int, timestamp string) string { + return "\"" + strconv.Itoa(version) + "-" + timestamp[:8] + "\"" +} diff --git a/admin-compliance/ai-compliance-sdk/internal/db/migrations/001_create_sdk_states.sql b/admin-compliance/ai-compliance-sdk/internal/db/migrations/001_create_sdk_states.sql new file mode 100644 index 0000000..38ce4a2 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/db/migrations/001_create_sdk_states.sql @@ -0,0 +1,60 @@ +-- Migration: Create SDK States Table +-- Description: Initial schema for SDK state persistence + +-- Enable UUID extension if not already enabled +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Create sdk_states table +CREATE TABLE IF NOT EXISTS sdk_states ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id VARCHAR(255) NOT NULL UNIQUE, + user_id VARCHAR(255), + state JSONB NOT NULL, + version INTEGER DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create index on tenant_id for fast lookups +CREATE INDEX IF NOT EXISTS idx_sdk_states_tenant ON sdk_states(tenant_id); + +-- Create index on updated_at for ordering +CREATE INDEX IF NOT EXISTS idx_sdk_states_updated ON sdk_states(updated_at DESC); + +-- Create trigger to automatically update updated_at +CREATE OR REPLACE FUNCTION update_sdk_states_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_sdk_states_updated_at ON sdk_states; +CREATE TRIGGER trigger_sdk_states_updated_at + BEFORE UPDATE ON sdk_states + FOR EACH ROW + EXECUTE FUNCTION update_sdk_states_updated_at(); + +-- Add comments +COMMENT ON TABLE sdk_states IS 'Stores SDK state for each tenant'; +COMMENT ON COLUMN sdk_states.tenant_id IS 'Unique identifier for the tenant'; +COMMENT ON COLUMN sdk_states.user_id IS 'User who last modified the state'; +COMMENT ON COLUMN sdk_states.state IS 'JSON state object'; +COMMENT ON COLUMN sdk_states.version IS 'Version number for optimistic locking'; + +-- Create cleanup function for old states (optional) +CREATE OR REPLACE FUNCTION cleanup_old_sdk_states(days_old INTEGER DEFAULT 365) +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM sdk_states + WHERE updated_at < NOW() - (days_old || ' days')::INTERVAL; + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION cleanup_old_sdk_states IS 'Removes SDK states older than specified days'; diff --git a/admin-compliance/ai-compliance-sdk/internal/db/postgres.go b/admin-compliance/ai-compliance-sdk/internal/db/postgres.go new file mode 100644 index 0000000..168d5cc --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/db/postgres.go @@ -0,0 +1,173 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Pool wraps a pgxpool.Pool with SDK-specific methods +type Pool struct { + *pgxpool.Pool +} + +// SDKState represents the state stored in the database +type SDKState struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + UserID string `json:"user_id,omitempty"` + State json.RawMessage `json:"state"` + Version int `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// NewPostgresPool creates a new database connection pool +func NewPostgresPool(connectionString string) (*Pool, error) { + config, err := pgxpool.ParseConfig(connectionString) + if err != nil { + return nil, fmt.Errorf("failed to parse connection string: %w", err) + } + + config.MaxConns = 10 + config.MinConns = 2 + config.MaxConnLifetime = 1 * time.Hour + config.MaxConnIdleTime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(context.Background(), config) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Test connection + if err := pool.Ping(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &Pool{Pool: pool}, nil +} + +// GetState retrieves state for a tenant +func (p *Pool) GetState(ctx context.Context, tenantID string) (*SDKState, error) { + query := ` + SELECT id, tenant_id, user_id, state, version, created_at, updated_at + FROM sdk_states + WHERE tenant_id = $1 + ` + + var state SDKState + err := p.QueryRow(ctx, query, tenantID).Scan( + &state.ID, + &state.TenantID, + &state.UserID, + &state.State, + &state.Version, + &state.CreatedAt, + &state.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &state, nil +} + +// SaveState saves or updates state for a tenant with optimistic locking +func (p *Pool) SaveState(ctx context.Context, tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) { + query := ` + INSERT INTO sdk_states (tenant_id, user_id, state, version) + VALUES ($1, $2, $3, 1) + ON CONFLICT (tenant_id) DO UPDATE SET + state = $3, + user_id = COALESCE($2, sdk_states.user_id), + version = sdk_states.version + 1, + updated_at = NOW() + WHERE ($4::int IS NULL OR sdk_states.version = $4) + RETURNING id, tenant_id, user_id, state, version, created_at, updated_at + ` + + var result SDKState + err := p.QueryRow(ctx, query, tenantID, userID, state, expectedVersion).Scan( + &result.ID, + &result.TenantID, + &result.UserID, + &result.State, + &result.Version, + &result.CreatedAt, + &result.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteState deletes state for a tenant +func (p *Pool) DeleteState(ctx context.Context, tenantID string) error { + query := `DELETE FROM sdk_states WHERE tenant_id = $1` + _, err := p.Exec(ctx, query, tenantID) + return err +} + +// InMemoryStore provides an in-memory fallback when database is not available +type InMemoryStore struct { + states map[string]*SDKState +} + +// NewInMemoryStore creates a new in-memory store +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{ + states: make(map[string]*SDKState), + } +} + +// GetState retrieves state from memory +func (s *InMemoryStore) GetState(tenantID string) (*SDKState, error) { + state, ok := s.states[tenantID] + if !ok { + return nil, fmt.Errorf("state not found") + } + return state, nil +} + +// SaveState saves state to memory +func (s *InMemoryStore) SaveState(tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) { + existing, exists := s.states[tenantID] + + // Optimistic locking check + if expectedVersion != nil && exists && existing.Version != *expectedVersion { + return nil, fmt.Errorf("version conflict") + } + + now := time.Now() + version := 1 + createdAt := now + + if exists { + version = existing.Version + 1 + createdAt = existing.CreatedAt + } + + newState := &SDKState{ + ID: fmt.Sprintf("%s-%d", tenantID, time.Now().UnixNano()), + TenantID: tenantID, + UserID: userID, + State: state, + Version: version, + CreatedAt: createdAt, + UpdatedAt: now, + } + + s.states[tenantID] = newState + return newState, nil +} + +// DeleteState deletes state from memory +func (s *InMemoryStore) DeleteState(tenantID string) error { + delete(s.states, tenantID) + return nil +} diff --git a/admin-compliance/ai-compliance-sdk/internal/llm/service.go b/admin-compliance/ai-compliance-sdk/internal/llm/service.go new file mode 100644 index 0000000..61a78e0 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/llm/service.go @@ -0,0 +1,384 @@ +package llm + +import ( + "context" + "fmt" + "strings" +) + +// SearchResult matches the RAG service result structure +type SearchResult struct { + ID string `json:"id"` + Content string `json:"content"` + Source string `json:"source"` + Score float64 `json:"score"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Service provides LLM functionality for document generation +type Service struct { + apiKey string + model string +} + +// NewService creates a new LLM service +func NewService(apiKey string) *Service { + model := "claude-3-5-sonnet-20241022" + if apiKey == "" { + model = "mock" + } + return &Service{ + apiKey: apiKey, + model: model, + } +} + +// GetModel returns the current model name +func (s *Service) GetModel() string { + return s.model +} + +// GenerateDSFA generates a Data Protection Impact Assessment +func (s *Service) GenerateDSFA(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + // Build prompt with context and RAG sources + prompt := s.buildDSFAPrompt(context, ragSources) + + // In production, this would call the Anthropic API + // response, err := s.callAnthropicAPI(ctx, prompt) + // if err != nil { + // return "", 0, err + // } + + // For now, simulate a response + content := s.generateDSFAContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 // Rough estimate + + return content, tokensUsed, nil +} + +// GenerateTOM generates Technical and Organizational Measures +func (s *Service) GenerateTOM(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + content := s.generateTOMContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 + + return content, tokensUsed, nil +} + +// GenerateVVT generates a Processing Activity Register +func (s *Service) GenerateVVT(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + content := s.generateVVTContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 + + return content, tokensUsed, nil +} + +// GenerateGutachten generates an expert opinion/assessment +func (s *Service) GenerateGutachten(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + content := s.generateGutachtenContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 + + return content, tokensUsed, nil +} + +// buildDSFAPrompt builds the prompt for DSFA generation +func (s *Service) buildDSFAPrompt(context map[string]interface{}, ragSources []SearchResult) string { + var sb strings.Builder + + sb.WriteString("Du bist ein Datenschutz-Experte und erstellst eine Datenschutz-Folgenabschätzung (DSFA) gemäß Art. 35 DSGVO.\n\n") + + // Add context + if useCaseName, ok := context["useCaseName"].(string); ok { + sb.WriteString(fmt.Sprintf("Use Case: %s\n", useCaseName)) + } + if description, ok := context["description"].(string); ok { + sb.WriteString(fmt.Sprintf("Beschreibung: %s\n", description)) + } + + // Add RAG context + if len(ragSources) > 0 { + sb.WriteString("\nRelevante rechtliche Grundlagen:\n") + for _, source := range ragSources { + sb.WriteString(fmt.Sprintf("- %s (%s)\n", source.Content[:min(200, len(source.Content))], source.Source)) + } + } + + sb.WriteString("\nErstelle eine vollständige DSFA mit allen erforderlichen Abschnitten.") + + return sb.String() +} + +// Content generation functions (would be replaced by actual LLM calls in production) +func (s *Service) generateDSFAContent(context map[string]interface{}, ragSources []SearchResult) string { + useCaseName := "KI-gestützte Datenverarbeitung" + if name, ok := context["useCaseName"].(string); ok { + useCaseName = name + } + + return fmt.Sprintf(`# Datenschutz-Folgenabschätzung (DSFA) + +## Use Case: %s + +## 1. Systematische Beschreibung der Verarbeitungsvorgänge + +Die geplante Verarbeitung umfasst die Analyse von Daten mittels KI-gestützter Systeme. + +### 1.1 Verarbeitungszwecke +- Automatisierte Analyse und Verarbeitung +- Optimierung von Geschäftsprozessen +- Qualitätssicherung + +### 1.2 Rechtsgrundlage +Gemäß Art. 6 Abs. 1 lit. f DSGVO basiert die Verarbeitung auf dem berechtigten Interesse des Verantwortlichen. + +### 1.3 Kategorien verarbeiteter Daten +- Nutzungsdaten +- Metadaten +- Aggregierte Analysedaten + +## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit + +### 2.1 Notwendigkeit +Die Verarbeitung ist erforderlich, um die definierten Geschäftsziele zu erreichen. + +### 2.2 Verhältnismäßigkeit +Alternative Methoden wurden geprüft. Die gewählte Verarbeitungsmethode stellt den geringsten Eingriff bei gleichem Nutzen dar. + +## 3. Risikobewertung + +### 3.1 Identifizierte Risiken + +| Risiko | Wahrscheinlichkeit | Schwere | Gesamtbewertung | +|--------|-------------------|---------|-----------------| +| Unbefugter Zugriff | Mittel | Hoch | HOCH | +| Datenverlust | Niedrig | Hoch | MITTEL | +| Fehlinterpretation | Mittel | Mittel | MITTEL | + +### 3.2 Maßnahmen zur Risikominderung + +1. **Technische Maßnahmen** + - Verschlüsselung (AES-256) + - Zugriffskontrollen + - Audit-Logging + +2. **Organisatorische Maßnahmen** + - Schulungen + - Dokumentation + - Regelmäßige Überprüfungen + +## 4. Genehmigungsstatus + +| Rolle | Status | Datum | +|-------|--------|-------| +| Projektleiter | AUSSTEHEND | - | +| DSB | AUSSTEHEND | - | +| Geschäftsführung | AUSSTEHEND | - | + +--- +*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.* +`, useCaseName) +} + +func (s *Service) generateTOMContent(context map[string]interface{}, ragSources []SearchResult) string { + return `# Technische und Organisatorische Maßnahmen (TOMs) + +## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO) + +### 1.1 Zutrittskontrolle +- [ ] Alarmanlage installiert +- [ ] Chipkarten-System aktiv +- [ ] Besucherprotokoll geführt + +### 1.2 Zugangskontrolle +- [ ] Starke Passwort-Policy (12+ Zeichen) +- [ ] MFA aktiviert +- [ ] Automatische Bildschirmsperre + +### 1.3 Zugriffskontrolle +- [ ] Rollenbasierte Berechtigungen +- [ ] Need-to-know Prinzip +- [ ] Quartalsweise Berechtigungsüberprüfung + +## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO) + +### 2.1 Weitergabekontrolle +- [ ] TLS 1.3 für alle Übertragungen +- [ ] E-Mail-Verschlüsselung +- [ ] Sichere File-Transfer-Protokolle + +### 2.2 Eingabekontrolle +- [ ] Vollständiges Audit-Logging +- [ ] Benutzeridentifikation bei Änderungen +- [ ] Unveränderliche Protokolle + +## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO) + +### 3.1 Verfügbarkeitskontrolle +- [ ] Tägliche Backups +- [ ] Georedundante Speicherung +- [ ] USV-System +- [ ] Dokumentierter Notfallplan + +### 3.2 Wiederherstellung +- [ ] RPO: 1 Stunde +- [ ] RTO: 4 Stunden +- [ ] Jährliche Wiederherstellungstests + +## 4. Belastbarkeit + +- [ ] DDoS-Schutz implementiert +- [ ] Lastverteilung aktiv +- [ ] Skalierbare Infrastruktur + +--- +*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.* +` +} + +func (s *Service) generateVVTContent(context map[string]interface{}, ragSources []SearchResult) string { + return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO) + +## Verarbeitungstätigkeit Nr. 1 + +### Stammdaten + +| Feld | Wert | +|------|------| +| **Bezeichnung** | KI-gestützte Datenanalyse | +| **Verantwortlicher** | [Unternehmen] | +| **DSB** | [Name, Kontakt] | +| **Abteilung** | IT / Data Science | + +### Verarbeitungsdetails + +| Feld | Wert | +|------|------| +| **Zweck** | Optimierung von Geschäftsprozessen durch KI-Analyse | +| **Rechtsgrundlage** | Art. 6 Abs. 1 lit. f DSGVO | +| **Betroffene Kategorien** | Kunden, Mitarbeiter, Geschäftspartner | +| **Datenkategorien** | Nutzungsdaten, Metadaten, Analyseergebnisse | + +### Empfänger + +| Kategorie | Beispiele | +|-----------|-----------| +| Intern | IT-Abteilung, Management | +| Auftragsverarbeiter | Cloud-Provider (mit AVV) | +| Dritte | Keine | + +### Drittlandtransfer + +| Frage | Antwort | +|-------|---------| +| Übermittlung in Drittländer? | Nein / Ja | +| Falls ja, Garantien | [Standardvertragsklauseln / Angemessenheitsbeschluss] | + +### Löschfristen + +| Datenkategorie | Frist | Grundlage | +|----------------|-------|-----------| +| Nutzungsdaten | 12 Monate | Betriebliche Notwendigkeit | +| Analyseergebnisse | 36 Monate | Geschäftszweck | +| Audit-Logs | 10 Jahre | Handelsrechtlich | + +### Technisch-Organisatorische Maßnahmen + +Verweis auf TOM-Dokument Version 1.0 + +--- +*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.* +` +} + +func (s *Service) generateGutachtenContent(context map[string]interface{}, ragSources []SearchResult) string { + return `# Compliance-Gutachten + +## Management Summary + +Das geprüfte System erfüllt die wesentlichen Anforderungen der anwendbaren Regulierungen. Es bestehen Optimierungspotenziale, die priorisiert adressiert werden sollten. + +## 1. Prüfungsumfang + +### 1.1 Geprüfte Regulierungen +- DSGVO (EU 2016/679) +- AI Act (EU 2024/...) +- NIS2 (EU 2022/2555) + +### 1.2 Prüfungsmethodik +- Dokumentenprüfung +- Technische Analyse +- Interviews mit Stakeholdern + +## 2. Ergebnisse + +### 2.1 DSGVO-Konformität + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Rechtmäßigkeit | ✓ Erfüllt | Gering | +| Transparenz | ◐ Teilweise | Mittel | +| Datensicherheit | ✓ Erfüllt | Gering | +| Betroffenenrechte | ◐ Teilweise | Mittel | + +### 2.2 AI Act-Konformität + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Risikoklassifizierung | ✓ Erfüllt | Keiner | +| Dokumentation | ◐ Teilweise | Mittel | +| Human Oversight | ✓ Erfüllt | Gering | + +### 2.3 NIS2-Konformität + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Risikomanagement | ✓ Erfüllt | Gering | +| Incident Reporting | ◐ Teilweise | Hoch | +| Supply Chain | ○ Nicht erfüllt | Kritisch | + +## 3. Empfehlungen + +### Kritisch (sofort) +1. Supply-Chain-Risikomanagement implementieren +2. Incident-Reporting-Prozess etablieren + +### Hoch (< 3 Monate) +3. Transparenzdokumentation vervollständigen +4. Betroffenenrechte-Portal optimieren + +### Mittel (< 6 Monate) +5. AI Act Dokumentation erweitern +6. Schulungsmaßnahmen durchführen + +## 4. Fazit + +Das System zeigt einen guten Compliance-Stand mit klar definierten Verbesserungsbereichen. Bei Umsetzung der Empfehlungen ist eine vollständige Konformität erreichbar. + +--- +*Erstellt: [Datum]* +*Gutachter: [Name]* +*Version: 1.0* +` +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/admin-compliance/ai-compliance-sdk/internal/rag/service.go b/admin-compliance/ai-compliance-sdk/internal/rag/service.go new file mode 100644 index 0000000..1366094 --- /dev/null +++ b/admin-compliance/ai-compliance-sdk/internal/rag/service.go @@ -0,0 +1,208 @@ +package rag + +import ( + "context" + "fmt" +) + +// SearchResult represents a search result from the RAG system +type SearchResult struct { + ID string `json:"id"` + Content string `json:"content"` + Source string `json:"source"` + Score float64 `json:"score"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CorpusStatus represents the status of the legal corpus +type CorpusStatus struct { + Status string `json:"status"` + Collections []string `json:"collections"` + Documents int `json:"documents"` + LastUpdated string `json:"lastUpdated,omitempty"` +} + +// Service provides RAG functionality +type Service struct { + qdrantURL string + // client *qdrant.Client // Would be actual Qdrant client in production +} + +// NewService creates a new RAG service +func NewService(qdrantURL string) (*Service, error) { + if qdrantURL == "" { + return nil, fmt.Errorf("qdrant URL is required") + } + + // In production, this would initialize the Qdrant client + // client, err := qdrant.NewClient(qdrantURL) + // if err != nil { + // return nil, err + // } + + return &Service{ + qdrantURL: qdrantURL, + }, nil +} + +// Search performs semantic search on the legal corpus +func (s *Service) Search(ctx context.Context, query string, topK int, collection string, filter string) ([]SearchResult, error) { + // In production, this would: + // 1. Generate embedding for the query using an embedding model (e.g., BGE-M3) + // 2. Search Qdrant for similar vectors + // 3. Return the results + + // For now, return mock results that simulate a real RAG response + results := s.getMockSearchResults(query, topK) + return results, nil +} + +// GetCorpusStatus returns the status of the legal corpus +func (s *Service) GetCorpusStatus(ctx context.Context) (*CorpusStatus, error) { + // In production, this would query Qdrant for collection info + return &CorpusStatus{ + Status: "ready", + Collections: []string{ + "legal_corpus", + "dsgvo_articles", + "ai_act_articles", + "nis2_articles", + }, + Documents: 1500, + LastUpdated: "2026-02-01T00:00:00Z", + }, nil +} + +// IndexDocument indexes a new document into the corpus +func (s *Service) IndexDocument(ctx context.Context, collection string, id string, content string, metadata map[string]string) error { + // In production, this would: + // 1. Generate embedding for the content + // 2. Store in Qdrant with the embedding and metadata + return nil +} + +// getMockSearchResults returns mock search results for development +func (s *Service) getMockSearchResults(query string, topK int) []SearchResult { + // Comprehensive mock data for legal searches + allResults := []SearchResult{ + // DSGVO Articles + { + ID: "dsgvo-art-5", + Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten müssen:\na) auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden („Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz");\nb) für festgelegte, eindeutige und legitime Zwecke erhoben werden und dürfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden („Zweckbindung");\nc) dem Zweck angemessen und erheblich sowie auf das für die Zwecke der Verarbeitung notwendige Maß beschränkt sein („Datenminimierung");", + Source: "DSGVO", + Score: 0.95, + Metadata: map[string]string{ + "article": "5", + "regulation": "DSGVO", + "category": "grundsaetze", + }, + }, + { + ID: "dsgvo-art-6", + Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung\n\n(1) Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der nachstehenden Bedingungen erfüllt ist:\na) Die betroffene Person hat ihre Einwilligung zu der Verarbeitung der sie betreffenden personenbezogenen Daten für einen oder mehrere bestimmte Zwecke gegeben;\nb) die Verarbeitung ist für die Erfüllung eines Vertrags erforderlich;\nc) die Verarbeitung ist zur Erfüllung einer rechtlichen Verpflichtung erforderlich;", + Source: "DSGVO", + Score: 0.92, + Metadata: map[string]string{ + "article": "6", + "regulation": "DSGVO", + "category": "rechtsgrundlage", + }, + }, + { + ID: "dsgvo-art-30", + Content: "Art. 30 DSGVO - Verzeichnis von Verarbeitungstätigkeiten\n\n(1) Jeder Verantwortliche und gegebenenfalls sein Vertreter führen ein Verzeichnis aller Verarbeitungstätigkeiten, die ihrer Zuständigkeit unterliegen. Dieses Verzeichnis enthält sämtliche folgenden Angaben:\na) den Namen und die Kontaktdaten des Verantwortlichen;\nb) die Zwecke der Verarbeitung;\nc) eine Beschreibung der Kategorien betroffener Personen und der Kategorien personenbezogener Daten;", + Source: "DSGVO", + Score: 0.89, + Metadata: map[string]string{ + "article": "30", + "regulation": "DSGVO", + "category": "dokumentation", + }, + }, + { + ID: "dsgvo-art-32", + Content: "Art. 32 DSGVO - Sicherheit der Verarbeitung\n\n(1) Unter Berücksichtigung des Stands der Technik, der Implementierungskosten und der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung sowie der unterschiedlichen Eintrittswahrscheinlichkeit und Schwere des Risikos für die Rechte und Freiheiten natürlicher Personen treffen der Verantwortliche und der Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten.", + Source: "DSGVO", + Score: 0.88, + Metadata: map[string]string{ + "article": "32", + "regulation": "DSGVO", + "category": "sicherheit", + }, + }, + { + ID: "dsgvo-art-35", + Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung\n\n(1) Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.", + Source: "DSGVO", + Score: 0.87, + Metadata: map[string]string{ + "article": "35", + "regulation": "DSGVO", + "category": "dsfa", + }, + }, + // AI Act Articles + { + ID: "ai-act-art-6", + Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme\n\n(1) Unbeschadet des Absatzes 2 gilt ein KI-System als Hochrisiko-KI-System, wenn es beide der folgenden Bedingungen erfüllt:\na) das KI-System soll als Sicherheitskomponente eines unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften der Union fallenden Produkts verwendet werden oder ist selbst ein solches Produkt;\nb) das Produkt, dessen Sicherheitskomponente das KI-System ist, oder das KI-System selbst muss einer Konformitätsbewertung durch Dritte unterzogen werden.", + Source: "AI Act", + Score: 0.91, + Metadata: map[string]string{ + "article": "6", + "regulation": "AI_ACT", + "category": "klassifizierung", + }, + }, + { + ID: "ai-act-art-9", + Content: "Art. 9 AI Act - Risikomanagement\n\n(1) Für Hochrisiko-KI-Systeme wird ein Risikomanagementsystem eingerichtet, umgesetzt, dokumentiert und aufrechterhalten. Das Risikomanagementsystem ist ein kontinuierlicher iterativer Prozess, der während des gesamten Lebenszyklus eines Hochrisiko-KI-Systems geplant und durchgeführt wird und einer regelmäßigen systematischen Aktualisierung bedarf.", + Source: "AI Act", + Score: 0.85, + Metadata: map[string]string{ + "article": "9", + "regulation": "AI_ACT", + "category": "risikomanagement", + }, + }, + { + ID: "ai-act-art-52", + Content: "Art. 52 AI Act - Transparenzpflichten für bestimmte KI-Systeme\n\n(1) Die Anbieter stellen sicher, dass KI-Systeme, die für die Interaktion mit natürlichen Personen bestimmt sind, so konzipiert und entwickelt werden, dass die betreffenden natürlichen Personen darüber informiert werden, dass sie mit einem KI-System interagieren, es sei denn, dies ist aus den Umständen und dem Nutzungskontext offensichtlich.", + Source: "AI Act", + Score: 0.83, + Metadata: map[string]string{ + "article": "52", + "regulation": "AI_ACT", + "category": "transparenz", + }, + }, + // NIS2 Articles + { + ID: "nis2-art-21", + Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen im Bereich der Cybersicherheit\n\n(1) Die Mitgliedstaaten stellen sicher, dass wesentliche und wichtige Einrichtungen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme, die diese Einrichtungen für ihren Betrieb oder die Erbringung ihrer Dienste nutzen, zu beherrschen und die Auswirkungen von Sicherheitsvorfällen auf die Empfänger ihrer Dienste und auf andere Dienste zu verhindern oder möglichst gering zu halten.", + Source: "NIS2", + Score: 0.86, + Metadata: map[string]string{ + "article": "21", + "regulation": "NIS2", + "category": "risikomanagement", + }, + }, + { + ID: "nis2-art-23", + Content: "Art. 23 NIS2 - Meldepflichten\n\n(1) Jeder Mitgliedstaat stellt sicher, dass wesentliche und wichtige Einrichtungen jeden Sicherheitsvorfall, der erhebliche Auswirkungen auf die Erbringung ihrer Dienste hat, unverzüglich dem zuständigen CSIRT oder gegebenenfalls der zuständigen Behörde melden.", + Source: "NIS2", + Score: 0.81, + Metadata: map[string]string{ + "article": "23", + "regulation": "NIS2", + "category": "meldepflicht", + }, + }, + } + + // Return top K results + if topK > len(allResults) { + topK = len(allResults) + } + return allResults[:topK] +} diff --git a/admin-compliance/app/(admin)/dashboard/catalog-manager/page.tsx b/admin-compliance/app/(admin)/dashboard/catalog-manager/page.tsx new file mode 100644 index 0000000..9948c94 --- /dev/null +++ b/admin-compliance/app/(admin)/dashboard/catalog-manager/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import { SDKProvider } from '@/lib/sdk/context' +import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent' + +export default function AdminCatalogManagerPage() { + return ( + + + + ) +} diff --git a/admin-compliance/app/(admin)/dashboard/page.tsx b/admin-compliance/app/(admin)/dashboard/page.tsx new file mode 100644 index 0000000..b6965b7 --- /dev/null +++ b/admin-compliance/app/(admin)/dashboard/page.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useEffect, useState } from 'react' +import { navigation, metaModules } from '@/lib/navigation' +import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles' +import { CategoryCard } from '@/components/common/ModuleCard' +import { InfoNote } from '@/components/common/InfoBox' +import { ServiceStatus } from '@/components/common/ServiceStatus' +import { NightModeWidget } from '@/components/dashboard/NightModeWidget' +import Link from 'next/link' + +interface Stats { + activeDocuments: number + openDSR: number + registeredUsers: number + totalConsents: number + gpuInstances: number +} + +export default function DashboardPage() { + const [stats, setStats] = useState({ + activeDocuments: 0, + openDSR: 0, + registeredUsers: 0, + totalConsents: 0, + gpuInstances: 0, + }) + const [loading, setLoading] = useState(true) + const [currentRole, setCurrentRole] = useState(null) + + useEffect(() => { + const role = getStoredRole() + setCurrentRole(role) + + // Load stats + const loadStats = async () => { + try { + const response = await fetch('http://localhost:8081/api/v1/admin/stats') + if (response.ok) { + const data = await response.json() + setStats({ + activeDocuments: data.documents_count || 0, + openDSR: data.open_dsr_count || 0, + registeredUsers: data.users_count || 0, + totalConsents: data.consents_count || 0, + gpuInstances: 0, + }) + } + } catch (error) { + console.log('Stats not available') + } finally { + setLoading(false) + } + } + + loadStats() + }, []) + + const statCards = [ + { label: 'Aktive Dokumente', value: stats.activeDocuments, color: 'text-green-600' }, + { label: 'Offene DSR', value: stats.openDSR, color: stats.openDSR > 0 ? 'text-orange-600' : 'text-slate-600' }, + { label: 'Registrierte Nutzer', value: stats.registeredUsers, color: 'text-blue-600' }, + { label: 'Zustimmungen', value: stats.totalConsents, color: 'text-purple-600' }, + { label: 'GPU Instanzen', value: stats.gpuInstances, color: 'text-pink-600' }, + ] + + const visibleCategories = currentRole + ? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole)) + : navigation + + return ( +
+ {/* Stats Grid */} +
+ {statCards.map((stat) => ( +
+
+ {loading ? '-' : stat.value} +
+
{stat.label}
+
+ ))} +
+ + {/* Categories */} +

Bereiche

+
+ {visibleCategories.map((category) => ( + + ))} +
+ + {/* Quick Links */} +

Schnellzugriff

+
+ {metaModules.filter(m => m.id !== 'dashboard').map((module) => ( + +
+ {module.id === 'onboarding' && '📖'} + {module.id === 'backlog' && '📋'} + {module.id === 'rbac' && '👥'} +
+
+

{module.name}

+

{module.description}

+
+ + ))} +
+ + {/* Infrastructure & System Status */} +

Infrastruktur

+
+ {/* Night Mode Widget */} + + + {/* System Status */} + +
+ + {/* Recent Activity */} +

Aktivitaet

+
+ {/* Recent DSR */} +
+
+

Neueste Datenschutzanfragen

+ + Alle anzeigen + +
+
+

+ Keine offenen Anfragen +

+
+
+
+ + {/* Info Box */} +
+ +

+ Dieses neue Admin-Frontend bietet eine verbesserte Navigation mit Kategorien und Rollen-basiertem Zugriff. + Das alte Admin-Frontend ist weiterhin unter Port 3000 verfuegbar. +

+
+
+
+ ) +} diff --git a/admin-compliance/app/(admin)/layout.tsx b/admin-compliance/app/(admin)/layout.tsx new file mode 100644 index 0000000..f73d6c2 --- /dev/null +++ b/admin-compliance/app/(admin)/layout.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { Sidebar } from '@/components/layout/Sidebar' +import { Header } from '@/components/layout/Header' +import { Breadcrumbs } from '@/components/common/Breadcrumbs' +import { getStoredRole } from '@/lib/roles' + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const router = useRouter() + const [sidebarKey, setSidebarKey] = useState(0) + const [loading, setLoading] = useState(true) + + useEffect(() => { + // Check if role is stored + const role = getStoredRole() + if (!role) { + // Redirect to role selection + router.replace('/') + } else { + setLoading(false) + } + }, [router]) + + const handleRoleChange = () => { + // Force sidebar to re-render + setSidebarKey(prev => prev + 1) + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Header */} +
+ + {/* Page Content */} +
+ + {children} +
+
+
+ ) +} diff --git a/admin-compliance/app/(sdk)/layout.tsx b/admin-compliance/app/(sdk)/layout.tsx new file mode 100644 index 0000000..fb78d27 --- /dev/null +++ b/admin-compliance/app/(sdk)/layout.tsx @@ -0,0 +1,173 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter, usePathname } from 'next/navigation' +import { SDKProvider } from '@/lib/sdk' +import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar' +import { CommandBar } from '@/components/sdk/CommandBar' +import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar' +import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget' +import { useSDK } from '@/lib/sdk' +import { getStoredRole } from '@/lib/roles' + +// ============================================================================= +// SDK HEADER +// ============================================================================= + +function SDKHeader({ sidebarCollapsed }: { sidebarCollapsed: boolean }) { + const { currentStep, setCommandBarOpen, completionPercentage } = useSDK() + + return ( +
+
+ {/* Breadcrumb / Current Step */} +
+ +
+ + {/* Actions */} +
+ {/* Command Bar Trigger */} + + + {/* Progress Indicator */} +
+
+
+
+ {completionPercentage}% +
+ + {/* Help Button */} + +
+
+
+ ) +} + +// ============================================================================= +// INNER LAYOUT (needs SDK context) +// ============================================================================= + +function SDKInnerLayout({ children }: { children: React.ReactNode }) { + const { isCommandBarOpen, setCommandBarOpen } = useSDK() + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const pathname = usePathname() + + // Extract current step from pathname (e.g., /sdk/vvt -> vvt) + const currentStep = pathname?.split('/').pop() || 'default' + + // Load collapsed state from localStorage + useEffect(() => { + const stored = localStorage.getItem('sdk-sidebar-collapsed') + if (stored !== null) { + setSidebarCollapsed(stored === 'true') + } + }, []) + + // Save collapsed state to localStorage + const handleCollapsedChange = (collapsed: boolean) => { + setSidebarCollapsed(collapsed) + localStorage.setItem('sdk-sidebar-collapsed', String(collapsed)) + } + + return ( +
+ {/* Sidebar */} + + + {/* Main Content - dynamic margin based on sidebar state */} +
+ {/* Header */} + + + {/* Page Content */} +
{children}
+
+ + {/* Command Bar Modal */} + {isCommandBarOpen && setCommandBarOpen(false)} />} + + {/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */} + + + {/* Compliance Advisor Widget */} + +
+ ) +} + +// ============================================================================= +// MAIN LAYOUT +// ============================================================================= + +export default function SDKRootLayout({ + children, +}: { + children: React.ReactNode +}) { + const router = useRouter() + const [loading, setLoading] = useState(true) + + useEffect(() => { + // Check if role is stored (auth check) + const role = getStoredRole() + if (!role) { + router.replace('/') + } else { + setLoading(false) + } + }, [router]) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( + + {children} + + ) +} diff --git a/admin-compliance/app/(sdk)/sdk/advisory-board/documentation/page.tsx b/admin-compliance/app/(sdk)/sdk/advisory-board/documentation/page.tsx new file mode 100644 index 0000000..a624c61 --- /dev/null +++ b/admin-compliance/app/(sdk)/sdk/advisory-board/documentation/page.tsx @@ -0,0 +1,596 @@ +'use client' + +/** + * UCCA System Documentation Page (SDK Version) + * + * Displays architecture documentation, auditor information, + * and transparency data for the UCCA compliance system. + */ + +import { useState, useEffect } from 'react' +import Link from 'next/link' + +// ============================================================================ +// Types +// ============================================================================ + +type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus' + +interface Rule { + code: string + category: string + title: string + description: string + severity: string + gdpr_ref: string + rationale?: string + risk_add?: number +} + +interface Pattern { + id: string + title: string + description: string + benefit?: string + effort?: string + risk_reduction?: number +} + +interface Control { + id: string + title: string + description: string + gdpr_ref?: string + effort?: string +} + +// ============================================================================ +// API Configuration +// ============================================================================ + +const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'https://macmini:8090' + +// ============================================================================ +// Main Component +// ============================================================================ + +export default function DocumentationPage() { + const [activeTab, setActiveTab] = useState('overview') + const [rules, setRules] = useState([]) + const [patterns, setPatterns] = useState([]) + const [controls, setControls] = useState([]) + const [policyVersion, setPolicyVersion] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + const fetchData = async () => { + setLoading(true) + try { + const rulesRes = await fetch(`${API_BASE}/sdk/v1/ucca/rules`, { + headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' } + }) + if (rulesRes.ok) { + const rulesData = await rulesRes.json() + setRules(rulesData.rules || []) + setPolicyVersion(rulesData.policy_version || '') + } + + const patternsRes = await fetch(`${API_BASE}/sdk/v1/ucca/patterns`, { + headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' } + }) + if (patternsRes.ok) { + const patternsData = await patternsRes.json() + setPatterns(patternsData.patterns || []) + } + + const controlsRes = await fetch(`${API_BASE}/sdk/v1/ucca/controls`, { + headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' } + }) + if (controlsRes.ok) { + const controlsData = await controlsRes.json() + setControls(controlsData.controls || []) + } + } catch (error) { + console.error('Failed to fetch documentation data:', error) + } finally { + setLoading(false) + } + } + + fetchData() + }, []) + + // ============================================================================ + // Tab Content Renderers + // ============================================================================ + + const renderOverview = () => ( +
+
+
+

Deterministische Regeln

+
{rules.length}
+

+ Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln. +

+
+
+

Architektur-Patterns

+
{patterns.length}
+

+ Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme. +

+
+
+

Compliance-Kontrollen

+
{controls.length}
+

+ Technische und organisatorische Massnahmen. +

+
+
+ +
+

Was ist UCCA?

+
+

+ UCCA (Use-Case Compliance & Feasibility Advisor) ist ein deterministisches + Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle + hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt. +

+

Kernprinzipien

+
    +
  • + Determinismus: Alle Entscheidungen basieren auf transparenten Regeln. + Die KI trifft KEINE autonomen Entscheidungen. +
  • +
  • + Transparenz: Alle Regeln, Kontrollen und Patterns sind einsehbar. +
  • +
  • + Human-in-the-Loop: Kritische Entscheidungen erfordern immer + menschliche Pruefung durch DSB oder Legal. +
  • +
  • + Rechtsgrundlage: Jede Regel referenziert konkrete DSGVO-Artikel. +
  • +
+
+
+ +
+

+ Wichtiger Hinweis zur KI-Nutzung +

+

+ Das System verwendet KI (LLM) ausschliesslich zur Erklaerung bereits + getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt + rein deterministisch durch die Policy Engine. BLOCK-Entscheidungen + koennen NICHT durch KI ueberschrieben werden. +

+
+
+ ) + + const renderArchitecture = () => ( +
+
+

Systemarchitektur

+ +
+
{`
+┌─────────────────────────────────────────────────────────────────────┐
+│                        Frontend (Next.js)                            │
+│                     admin-v2:3000/sdk/advisory-board                 │
+└───────────────────────────────────┬─────────────────────────────────┘
+                                    │ HTTPS
+                                    ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│                     AI Compliance SDK (Go)                           │
+│                          Port 8090                                   │
+│  ┌─────────────────────────────────────────────────────────────┐   │
+│  │                    Policy Engine                              │   │
+│  │  ┌───────────────────────────────────────────────────────┐   │   │
+│  │  │  YAML-basierte Regeln (ucca_policy_v1.yaml)           │   │   │
+│  │  │  ~45 Regeln in 7 Kategorien                           │   │   │
+│  │  │  Deterministisch - Kein LLM in Entscheidungslogik     │   │   │
+│  │  └───────────────────────────────────────────────────────┘   │   │
+│  │                          │                                    │   │
+│  │                          ▼                                    │   │
+│  │  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐  │   │
+│  │  │  Controls      │  │  Patterns      │  │  Examples      │  │   │
+│  │  │  Library       │  │  Library       │  │  Library       │  │   │
+│  │  └────────────────┘  └────────────────┘  └────────────────┘  │   │
+│  └─────────────────────────────────────────────────────────────┘   │
+│                                                                      │
+│  ┌──────────────────┐  ┌──────────────────┐                        │
+│  │  LLM Integration │  │  Legal RAG       │──────┐                  │
+│  │  (nur Explain)   │  │  Client          │      │                  │
+│  └──────────────────┘  └──────────────────┘      │                  │
+└─────────────────────────────┬────────────────────┼──────────────────┘
+                              │                    │
+                              ▼                    ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│                        Datenschicht                                  │
+│  ┌────────────────────┐  ┌────────────────────┐                     │
+│  │    PostgreSQL      │  │    Qdrant          │                     │
+│  │    (Assessments,   │  │    (Legal Corpus,  │                     │
+│  │     Escalations)   │  │     2,274 Chunks)  │                     │
+│  └────────────────────┘  └────────────────────┘                     │
+└─────────────────────────────────────────────────────────────────────┘
+          `}
+
+ +
+
+

Datenfluss

+
    +
  1. Benutzer beschreibt Use Case im Frontend
  2. +
  3. Policy Engine evaluiert gegen alle Regeln
  4. +
  5. Ergebnis mit Controls + Patterns zurueck
  6. +
  7. Optional: LLM erklaert das Ergebnis
  8. +
  9. Bei Risiko: Automatische Eskalation
  10. +
+
+
+

Sicherheitsmerkmale

+
    +
  • TLS 1.3 Verschluesselung
  • +
  • RBAC mit Tenant-Isolation
  • +
  • JWT-basierte Authentifizierung
  • +
  • Audit-Trail aller Aktionen
  • +
  • Keine Rohtext-Speicherung (nur Hash)
  • +
+
+
+
+ +
+

Eskalations-Workflow

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelAusloeserPrueferSLA
E0Nur INFO-Regeln, Risiko < 20Automatisch-
E1WARN-Regeln, Risiko 20-40Team-Lead24h / 72h
E2Art. 9 Daten, DSFA empfohlen, Risiko 40-60DSB8h / 48h
E3BLOCK-Regeln, Art. 22, Risiko > 60DSB + Legal4h / 24h
+
+
+
+ ) + + const renderAuditorInfo = () => ( +
+
+

+ Dokumentation fuer externe Auditoren +

+

+ Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von + Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO. +

+ +
+
+

1. Zweck des Systems

+

+ UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle + hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit. +

+
+ +
+

2. Rechtsgrundlage

+
    +
  • Art. 6 Abs. 1 lit. c DSGVO - Erfuellung rechtlicher Verpflichtungen
  • +
  • Art. 6 Abs. 1 lit. f DSGVO - Berechtigte Interessen (Compliance-Management)
  • +
+
+ +
+

3. Verarbeitete Datenkategorien

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KategorieSpeicherungAufbewahrung
Use-Case-BeschreibungNur Hash (SHA-256)10 Jahre
BewertungsergebnisVollstaendig10 Jahre
Audit-TrailVollstaendig10 Jahre
Eskalations-HistorieVollstaendig10 Jahre
+
+
+ +
+

4. Keine autonomen KI-Entscheidungen

+

+ Das System trifft KEINE automatisierten Einzelentscheidungen im Sinne + von Art. 22 DSGVO, da: +

+
    +
  • Regelauswertung ist keine rechtlich bindende Entscheidung
  • +
  • Alle kritischen Faelle werden menschlich geprueft (E1-E3)
  • +
  • BLOCK-Entscheidungen erfordern immer menschliche Freigabe
  • +
  • Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation
  • +
+
+ +
+

5. Technische und Organisatorische Massnahmen

+
+
+ Vertraulichkeit +
    +
  • RBAC mit Tenant-Isolation
  • +
  • TLS 1.3 Verschluesselung
  • +
  • AES-256 at rest
  • +
+
+
+ Integritaet +
    +
  • Unveraenderlicher Audit-Trail
  • +
  • Policy-Versionierung
  • +
  • Input-Validierung
  • +
+
+
+
+
+
+
+ ) + + const renderRulesTab = () => ( +
+
+
+

Regel-Katalog

+

Policy Version: {policyVersion}

+
+
+ {rules.length} Regeln insgesamt +
+
+ + {loading ? ( +
Lade Regeln...
+ ) : ( +
+ {Array.from(new Set(rules.map(r => r.category))).map(category => ( +
+
+

{category}

+

+ {rules.filter(r => r.category === category).length} Regeln +

+
+
+ {rules.filter(r => r.category === category).map(rule => ( +
+
+
+
+ {rule.code} + + {rule.severity} + +
+
{rule.title}
+
{rule.description}
+ {rule.gdpr_ref && ( +
{rule.gdpr_ref}
+ )} +
+ {rule.risk_add && ( +
+ +{rule.risk_add} +
+ )} +
+
+ ))} +
+
+ ))} +
+ )} +
+ ) + + const renderLegalCorpus = () => ( +
+
+

Legal RAG Corpus

+

+ Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen + fuer rechtsgrundlagenbasierte Erklaerungen. +

+ +
+
+

Indexierte Regulierungen

+
    +
  • DSGVO - Datenschutz-Grundverordnung
  • +
  • AI Act - EU KI-Verordnung
  • +
  • NIS2 - Cybersicherheits-Richtlinie
  • +
  • CRA - Cyber Resilience Act
  • +
  • Data Act - Datengesetz
  • +
  • DSA/DMA - Digital Services/Markets Act
  • +
  • DPF - EU-US Data Privacy Framework
  • +
  • BSI-TR-03161 - Digitale Identitaeten
  • +
+
+
+

RAG-Funktionalitaet

+
    +
  • Hybride Suche (Dense + BM25)
  • +
  • Semantisches Chunking
  • +
  • Cross-Encoder Reranking
  • +
  • Artikel-Referenz-Extraktion
  • +
  • Mehrsprachig (DE/EN)
  • +
+
+
+
+ +
+

Verwendung im System

+
+
+
+ 1 +
+
+
Benutzer fordert Erklaerung an
+
+ Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden. +
+
+
+
+
+ 2 +
+
+
Legal RAG Client sucht relevante Artikel
+
+ Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden. +
+
+
+
+
+ 3 +
+
+
LLM generiert Erklaerung mit Rechtsgrundlage
+
+ Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc. +
+
+
+
+
+
+ ) + + // ============================================================================ + // Tabs Configuration + // ============================================================================ + + const tabs: { id: DocTab; label: string }[] = [ + { id: 'overview', label: 'Uebersicht' }, + { id: 'architecture', label: 'Architektur' }, + { id: 'auditor', label: 'Fuer Auditoren' }, + { id: 'rules', label: 'Regel-Katalog' }, + { id: 'legal-corpus', label: 'Legal RAG' }, + ] + + // ============================================================================ + // Main Render + // ============================================================================ + + return ( +
+
+
+

UCCA System-Dokumentation

+

+ Transparente Dokumentation des UCCA-Systems fuer Entwickler, Auditoren und Datenschutzbeauftragte. +

+
+ + Zurueck zum Advisory Board + +
+ + {/* Tab Navigation */} +
+
+ {tabs.map(tab => ( + + ))} +
+ +
+ {activeTab === 'overview' && renderOverview()} + {activeTab === 'architecture' && renderArchitecture()} + {activeTab === 'auditor' && renderAuditorInfo()} + {activeTab === 'rules' && renderRulesTab()} + {activeTab === 'legal-corpus' && renderLegalCorpus()} +
+
+
+ ) +} diff --git a/admin-compliance/app/(sdk)/sdk/advisory-board/page.tsx b/admin-compliance/app/(sdk)/sdk/advisory-board/page.tsx new file mode 100644 index 0000000..1276a80 --- /dev/null +++ b/admin-compliance/app/(sdk)/sdk/advisory-board/page.tsx @@ -0,0 +1,667 @@ +'use client' + +import React, { useState } from 'react' +import Link from 'next/link' +import { useSDK, UseCaseAssessment } from '@/lib/sdk' + +// ============================================================================= +// WIZARD STEPS +// ============================================================================= + +const WIZARD_STEPS = [ + { id: 1, name: 'Grunddaten', description: 'Name und Beschreibung des Use Cases' }, + { id: 2, name: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' }, + { id: 3, name: 'Technologie', description: 'Eingesetzte KI-Technologien' }, + { id: 4, name: 'Risikobewertung', description: 'Erste Risikoeinschätzung' }, + { id: 5, name: 'Zusammenfassung', description: 'Überprüfung und Abschluss' }, +] + +// ============================================================================= +// USE CASE CARD +// ============================================================================= + +function UseCaseCard({ + useCase, + isActive, + onSelect, + onDelete, +}: { + useCase: UseCaseAssessment + isActive: boolean + onSelect: () => void + onDelete: () => void +}) { + const completionPercent = Math.round((useCase.stepsCompleted / 5) * 100) + + return ( +
+ {/* Delete Button */} + + +
+
+ {completionPercent === 100 ? ( + + + + ) : ( + + + + )} +
+
+

{useCase.name}

+

{useCase.description}

+
+
+ Fortschritt + {completionPercent}% +
+
+
+
+
+ {useCase.assessmentResult && ( +
+ + Risiko: {useCase.assessmentResult.riskLevel} + + {useCase.assessmentResult.dsfaRequired && ( + + DSFA erforderlich + + )} +
+ )} +
+
+
+ ) +} + +// ============================================================================= +// WIZARD +// ============================================================================= + +interface WizardFormData { + name: string + description: string + category: string + dataCategories: string[] + processesPersonalData: boolean + specialCategories: boolean + aiTechnologies: string[] + dataVolume: string + riskLevel: string + notes: string +} + +function UseCaseWizard({ + onComplete, + onCancel, +}: { + onComplete: (useCase: UseCaseAssessment) => void + onCancel: () => void +}) { + const [currentStep, setCurrentStep] = useState(1) + const [formData, setFormData] = useState({ + name: '', + description: '', + category: '', + dataCategories: [], + processesPersonalData: false, + specialCategories: false, + aiTechnologies: [], + dataVolume: 'medium', + riskLevel: 'medium', + notes: '', + }) + + const updateFormData = (updates: Partial) => { + setFormData(prev => ({ ...prev, ...updates })) + } + + const handleNext = () => { + if (currentStep < 5) { + setCurrentStep(prev => prev + 1) + } else { + // Create use case + const newUseCase: UseCaseAssessment = { + id: `uc-${Date.now()}`, + name: formData.name, + description: formData.description, + category: formData.category, + stepsCompleted: 5, + steps: WIZARD_STEPS.map(s => ({ + id: `step-${s.id}`, + name: s.name, + completed: true, + data: {}, + })), + assessmentResult: { + riskLevel: formData.riskLevel as 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL', + applicableRegulations: ['DSGVO', 'AI Act'], + recommendedControls: ['Datenschutz-Folgenabschätzung', 'Technische Maßnahmen'], + dsfaRequired: formData.specialCategories || formData.riskLevel === 'HIGH', + aiActClassification: formData.aiTechnologies.length > 0 ? 'LIMITED' : 'MINIMAL', + }, + createdAt: new Date(), + updatedAt: new Date(), + } + onComplete(newUseCase) + } + } + + const handleBack = () => { + if (currentStep > 1) { + setCurrentStep(prev => prev - 1) + } + } + + return ( +
+ {/* Header */} +
+
+

Neuer Use Case

+ +
+ {/* Progress */} +
+ {WIZARD_STEPS.map((step, index) => ( + +
+ {step.id < currentStep ? ( + + + + ) : ( + step.id + )} +
+ {index < WIZARD_STEPS.length - 1 && ( +
+ )} + + ))} +
+

+ Schritt {currentStep}: {WIZARD_STEPS[currentStep - 1].description} +

+
+ + {/* Content */} +
+ {currentStep === 1 && ( +
+
+ + updateFormData({ name: e.target.value })} + placeholder="z.B. Marketing-KI für Kundensegmentierung" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+
+ + +
+ + ${this.error ? `
${this.error}
` : ''} + + + + +

${t.disclaimer}

+
+ ` + + // Bind events + const form = this.shadow.getElementById('dsr-form') as HTMLFormElement + form.onsubmit = this.handleSubmit + + const nameInput = this.shadow.getElementById('name-input') as HTMLInputElement + nameInput.oninput = e => { + this.name = (e.target as HTMLInputElement).value + } + + const emailInput = this.shadow.getElementById('email-input') as HTMLInputElement + emailInput.oninput = e => { + this.email = (e.target as HTMLInputElement).value + } + + const infoInput = this.shadow.getElementById('info-input') as HTMLTextAreaElement + infoInput.oninput = e => { + this.additionalInfo = (e.target as HTMLTextAreaElement).value + } + + // Bind radio buttons + this.shadow.querySelectorAll('input[name="dsrType"]').forEach(radio => { + radio.onchange = () => { + this.handleTypeSelect(radio.value as DSRRequestType) + } + }) + } + + private renderSuccess(styles: string): void { + const t = this.t + + this.shadow.innerHTML = ` + +
+
+
+

${t.successTitle}

+

+ ${t.successMessage} ${this.email}. +

+
+
+ ` + } +} + +// Register the custom element +if (typeof customElements !== 'undefined') { + customElements.define('breakpilot-dsr-portal', DSRPortalElement) +} diff --git a/breakpilot-compliance-sdk/packages/vanilla/src/web-components/index.ts b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/index.ts new file mode 100644 index 0000000..d2ca088 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/index.ts @@ -0,0 +1,13 @@ +/** + * BreakPilot Compliance SDK - Web Components + * + * Available components: + * - + * - + * - + */ + +export { BreakPilotElement, COMMON_STYLES } from './base' +export { ConsentBannerElement } from './consent-banner' +export { DSRPortalElement } from './dsr-portal' +export { ComplianceScoreElement } from './compliance-score' diff --git a/breakpilot-compliance-sdk/packages/vanilla/tsconfig.json b/breakpilot-compliance-sdk/packages/vanilla/tsconfig.json new file mode 100644 index 0000000..f6bbb79 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/breakpilot-compliance-sdk/packages/vanilla/tsup.config.ts b/breakpilot-compliance-sdk/packages/vanilla/tsup.config.ts new file mode 100644 index 0000000..f6764e6 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/tsup.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsup' + +export default defineConfig([ + // Main bundle with web components + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + }, + // Embed script (IIFE for + + + + + + +``` + +## Consent-Kategorien + +| Kategorie | Beschreibung | Einwilligung | +|-----------|--------------|--------------| +| `essential` | Technisch notwendig | Nicht erforderlich | +| `functional` | Personalisierung | Erforderlich | +| `analytics` | Nutzungsanalyse | Erforderlich | +| `marketing` | Werbung | Erforderlich | +| `social` | Social Media | Erforderlich | + +## Konfiguration + +```typescript +const config: ConsentConfig = { + // Pflicht + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + + // Sprache + language: 'de', + fallbackLanguage: 'en', + + // UI + ui: { + position: 'bottom', // 'bottom' | 'top' | 'center' + layout: 'modal', // 'bar' | 'modal' | 'floating' + theme: 'auto', // 'light' | 'dark' | 'auto' + zIndex: 999999, + }, + + // Verhalten + consent: { + required: true, + rejectAllVisible: true, // "Alle ablehnen" Button + acceptAllVisible: true, // "Alle akzeptieren" Button + granularControl: true, // Kategorien einzeln waehlbar + rememberDays: 365, // Speicherdauer + recheckAfterDays: 180, // Erneut fragen nach X Tagen + }, + + // Callbacks + onConsentChange: (consent) => { + console.log('Consent:', consent); + }, + + // Debug + debug: process.env.NODE_ENV === 'development', +}; +``` + +## API + +### ConsentManager + +```typescript +// Initialisieren +await consent.init(); + +// Consent pruefen +consent.hasConsent('analytics'); // boolean +consent.hasVendorConsent('google'); // boolean + +// Consent abrufen +consent.getConsent(); // ConsentState | null + +// Consent setzen +await consent.setConsent({ + essential: true, + analytics: true, + marketing: false, +}); + +// Aktionen +await consent.acceptAll(); +await consent.rejectAll(); +await consent.revokeAll(); + +// Banner +consent.showBanner(); +consent.hideBanner(); +consent.showSettings(); +consent.needsConsent(); // boolean + +// Events +consent.on('change', callback); +consent.on('banner_show', callback); +consent.on('banner_hide', callback); +consent.off('change', callback); + +// Export (DSGVO Art. 20) +const data = await consent.exportConsent(); +``` + +### React Hooks + +```typescript +// Basis-Hook +const { + consent, + isLoading, + isBannerVisible, + needsConsent, + hasConsent, + acceptAll, + rejectAll, + showBanner, + showSettings, +} = useConsent(); + +// Mit Kategorie +const { allowed } = useConsent('analytics'); + +// Manager-Zugriff +const manager = useConsentManager(); +``` + +## Rechtliche Compliance + +Dieses SDK erfuellt: + +- **DSGVO** (EU 2016/679) - Art. 4, 6, 7, 12, 13, 17, 20 +- **TTDSG** (Deutschland) - § 25 +- **ePrivacy-Richtlinie** (2002/58/EG) - Art. 5 +- **Planet49-Urteil** (EuGH C-673/17) +- **BGH Cookie-Einwilligung II** (2023) +- **DSK Orientierungshilfe Telemedien** + +## Lizenz + +Apache 2.0 - Open Source, kommerziell nutzbar. + +``` +Copyright 2026 BreakPilot GmbH + +Licensed under the Apache License, Version 2.0 +``` diff --git a/consent-sdk/package-lock.json b/consent-sdk/package-lock.json new file mode 100644 index 0000000..de8a4d3 --- /dev/null +++ b/consent-sdk/package-lock.json @@ -0,0 +1,5403 @@ +{ + "name": "@breakpilot/consent-sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@breakpilot/consent-sdk", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitest/coverage-v8": "^1.2.1", + "eslint": "^8.56.0", + "jsdom": "^28.0.0", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "vitest": "^1.2.1", + "vue": "^3.5.27" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", + "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", + "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", + "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.31.tgz", + "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", + "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.22" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", + "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", + "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/consent-sdk/package.json b/consent-sdk/package.json new file mode 100644 index 0000000..6d4c317 --- /dev/null +++ b/consent-sdk/package.json @@ -0,0 +1,94 @@ +{ + "name": "@breakpilot/consent-sdk", + "version": "1.0.0", + "description": "DSGVO/TTDSG-konformes Consent Management SDK für Web, PWA und Mobile Apps", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.esm.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react/index.esm.js", + "require": "./dist/react/index.js", + "types": "./dist/react/index.d.ts" + }, + "./vue": { + "import": "./dist/vue/index.esm.js", + "require": "./dist/vue/index.js", + "types": "./dist/vue/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "test:coverage": "vitest --coverage", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "consent", + "cookie", + "gdpr", + "dsgvo", + "ttdsg", + "privacy", + "cookie-banner", + "consent-management", + "tcf" + ], + "author": "BreakPilot GmbH", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/breakpilot/consent-sdk.git" + }, + "homepage": "https://github.com/breakpilot/consent-sdk#readme", + "bugs": { + "url": "https://github.com/breakpilot/consent-sdk/issues" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitest/coverage-v8": "^1.2.1", + "eslint": "^8.56.0", + "jsdom": "^28.0.0", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "vitest": "^1.2.1", + "vue": "^3.5.27" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/consent-sdk/src/angular/index.ts b/consent-sdk/src/angular/index.ts new file mode 100644 index 0000000..6c4ece4 --- /dev/null +++ b/consent-sdk/src/angular/index.ts @@ -0,0 +1,509 @@ +/** + * Angular Integration fuer @breakpilot/consent-sdk + * + * @example + * ```typescript + * // app.module.ts + * import { ConsentModule } from '@breakpilot/consent-sdk/angular'; + * + * @NgModule({ + * imports: [ + * ConsentModule.forRoot({ + * apiEndpoint: 'https://consent.example.com/api/v1', + * siteId: 'site_abc123', + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ + +// ============================================================================= +// NOTE: Angular SDK Structure +// ============================================================================= +// +// Angular hat ein komplexeres Build-System (ngc, ng-packagr). +// Diese Datei definiert die Schnittstelle - fuer Production muss ein +// separates Angular Library Package erstellt werden: +// +// ng generate library @breakpilot/consent-sdk-angular +// +// Die folgende Implementation ist fuer direkten Import vorgesehen. +// ============================================================================= + +import { ConsentManager } from '../core/ConsentManager'; +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, +} from '../types'; + +// ============================================================================= +// Angular Service Interface +// ============================================================================= + +/** + * ConsentService Interface fuer Angular DI + * + * @example + * ```typescript + * @Component({...}) + * export class MyComponent { + * constructor(private consent: ConsentService) { + * if (this.consent.hasConsent('analytics')) { + * // Analytics laden + * } + * } + * } + * ``` + */ +export interface IConsentService { + /** Initialisiert? */ + readonly isInitialized: boolean; + + /** Laedt noch? */ + readonly isLoading: boolean; + + /** Banner sichtbar? */ + readonly isBannerVisible: boolean; + + /** Aktueller Consent-Zustand */ + readonly consent: ConsentState | null; + + /** Muss Consent eingeholt werden? */ + readonly needsConsent: boolean; + + /** Prueft Consent fuer Kategorie */ + hasConsent(category: ConsentCategory): boolean; + + /** Alle akzeptieren */ + acceptAll(): Promise; + + /** Alle ablehnen */ + rejectAll(): Promise; + + /** Auswahl speichern */ + saveSelection(categories: Partial): Promise; + + /** Banner anzeigen */ + showBanner(): void; + + /** Banner ausblenden */ + hideBanner(): void; + + /** Einstellungen oeffnen */ + showSettings(): void; +} + +// ============================================================================= +// ConsentService Implementation +// ============================================================================= + +/** + * ConsentService - Angular Service Wrapper + * + * Diese Klasse kann als Angular Service registriert werden: + * + * @example + * ```typescript + * // consent.service.ts + * import { Injectable } from '@angular/core'; + * import { ConsentServiceBase } from '@breakpilot/consent-sdk/angular'; + * + * @Injectable({ providedIn: 'root' }) + * export class ConsentService extends ConsentServiceBase { + * constructor() { + * super({ + * apiEndpoint: environment.consentApiEndpoint, + * siteId: environment.siteId, + * }); + * } + * } + * ``` + */ +export class ConsentServiceBase implements IConsentService { + private manager: ConsentManager; + private _consent: ConsentState | null = null; + private _isInitialized = false; + private _isLoading = true; + private _isBannerVisible = false; + + // Callbacks fuer Angular Change Detection + private changeCallbacks: Array<(consent: ConsentState) => void> = []; + private bannerShowCallbacks: Array<() => void> = []; + private bannerHideCallbacks: Array<() => void> = []; + + constructor(config: ConsentConfig) { + this.manager = new ConsentManager(config); + this.setupEventListeners(); + this.initialize(); + } + + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + + get isInitialized(): boolean { + return this._isInitialized; + } + + get isLoading(): boolean { + return this._isLoading; + } + + get isBannerVisible(): boolean { + return this._isBannerVisible; + } + + get consent(): ConsentState | null { + return this._consent; + } + + get needsConsent(): boolean { + return this.manager.needsConsent(); + } + + // --------------------------------------------------------------------------- + // Methods + // --------------------------------------------------------------------------- + + hasConsent(category: ConsentCategory): boolean { + return this.manager.hasConsent(category); + } + + async acceptAll(): Promise { + await this.manager.acceptAll(); + } + + async rejectAll(): Promise { + await this.manager.rejectAll(); + } + + async saveSelection(categories: Partial): Promise { + await this.manager.setConsent(categories); + this.manager.hideBanner(); + } + + showBanner(): void { + this.manager.showBanner(); + } + + hideBanner(): void { + this.manager.hideBanner(); + } + + showSettings(): void { + this.manager.showSettings(); + } + + // --------------------------------------------------------------------------- + // Change Detection Support + // --------------------------------------------------------------------------- + + /** + * Registriert Callback fuer Consent-Aenderungen + * (fuer Angular Change Detection) + */ + onConsentChange(callback: (consent: ConsentState) => void): () => void { + this.changeCallbacks.push(callback); + return () => { + const index = this.changeCallbacks.indexOf(callback); + if (index > -1) { + this.changeCallbacks.splice(index, 1); + } + }; + } + + /** + * Registriert Callback wenn Banner angezeigt wird + */ + onBannerShow(callback: () => void): () => void { + this.bannerShowCallbacks.push(callback); + return () => { + const index = this.bannerShowCallbacks.indexOf(callback); + if (index > -1) { + this.bannerShowCallbacks.splice(index, 1); + } + }; + } + + /** + * Registriert Callback wenn Banner ausgeblendet wird + */ + onBannerHide(callback: () => void): () => void { + this.bannerHideCallbacks.push(callback); + return () => { + const index = this.bannerHideCallbacks.indexOf(callback); + if (index > -1) { + this.bannerHideCallbacks.splice(index, 1); + } + }; + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private setupEventListeners(): void { + this.manager.on('change', (consent) => { + this._consent = consent; + this.changeCallbacks.forEach((cb) => cb(consent)); + }); + + this.manager.on('banner_show', () => { + this._isBannerVisible = true; + this.bannerShowCallbacks.forEach((cb) => cb()); + }); + + this.manager.on('banner_hide', () => { + this._isBannerVisible = false; + this.bannerHideCallbacks.forEach((cb) => cb()); + }); + } + + private async initialize(): Promise { + try { + await this.manager.init(); + this._consent = this.manager.getConsent(); + this._isInitialized = true; + this._isBannerVisible = this.manager.isBannerVisible(); + } catch (error) { + console.error('Failed to initialize ConsentManager:', error); + } finally { + this._isLoading = false; + } + } +} + +// ============================================================================= +// Angular Module Configuration +// ============================================================================= + +/** + * Konfiguration fuer ConsentModule.forRoot() + */ +export interface ConsentModuleConfig extends ConsentConfig {} + +/** + * Token fuer Dependency Injection + * Verwendung mit Angular @Inject(): + * + * @example + * ```typescript + * constructor(@Inject(CONSENT_CONFIG) private config: ConsentConfig) {} + * ``` + */ +export const CONSENT_CONFIG = 'CONSENT_CONFIG'; +export const CONSENT_SERVICE = 'CONSENT_SERVICE'; + +// ============================================================================= +// Factory Functions fuer Angular DI +// ============================================================================= + +/** + * Factory fuer ConsentService + * + * @example + * ```typescript + * // app.module.ts + * providers: [ + * { provide: CONSENT_CONFIG, useValue: { apiEndpoint: '...', siteId: '...' } }, + * { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] }, + * ] + * ``` + */ +export function consentServiceFactory(config: ConsentConfig): ConsentServiceBase { + return new ConsentServiceBase(config); +} + +// ============================================================================= +// Angular Module Definition (Template) +// ============================================================================= + +/** + * ConsentModule - Angular Module + * + * Dies ist eine Template-Definition. Fuer echte Angular-Nutzung + * muss ein separates Angular Library Package erstellt werden. + * + * @example + * ```typescript + * // In einem Angular Library Package: + * @NgModule({ + * declarations: [ConsentBannerComponent, ConsentGateDirective], + * exports: [ConsentBannerComponent, ConsentGateDirective], + * }) + * export class ConsentModule { + * static forRoot(config: ConsentModuleConfig): ModuleWithProviders { + * return { + * ngModule: ConsentModule, + * providers: [ + * { provide: CONSENT_CONFIG, useValue: config }, + * { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] }, + * ], + * }; + * } + * } + * ``` + */ +export const ConsentModuleDefinition = { + /** + * Providers fuer Root-Module + */ + forRoot: (config: ConsentModuleConfig) => ({ + provide: CONSENT_CONFIG, + useValue: config, + }), +}; + +// ============================================================================= +// Component Templates (fuer Angular Library) +// ============================================================================= + +/** + * ConsentBannerComponent Template + * + * Fuer Angular Library Implementation: + * + * @example + * ```typescript + * @Component({ + * selector: 'bp-consent-banner', + * template: CONSENT_BANNER_TEMPLATE, + * styles: [CONSENT_BANNER_STYLES], + * }) + * export class ConsentBannerComponent { + * constructor(public consent: ConsentService) {} + * } + * ``` + */ +export const CONSENT_BANNER_TEMPLATE = ` + +`; + +/** + * ConsentGateDirective Template + * + * @example + * ```typescript + * @Directive({ + * selector: '[bpConsentGate]', + * }) + * export class ConsentGateDirective implements OnInit, OnDestroy { + * @Input('bpConsentGate') category!: ConsentCategory; + * + * private unsubscribe?: () => void; + * + * constructor( + * private templateRef: TemplateRef, + * private viewContainer: ViewContainerRef, + * private consent: ConsentService + * ) {} + * + * ngOnInit() { + * this.updateView(); + * this.unsubscribe = this.consent.onConsentChange(() => this.updateView()); + * } + * + * ngOnDestroy() { + * this.unsubscribe?.(); + * } + * + * private updateView() { + * if (this.consent.hasConsent(this.category)) { + * this.viewContainer.createEmbeddedView(this.templateRef); + * } else { + * this.viewContainer.clear(); + * } + * } + * } + * ``` + */ +export const CONSENT_GATE_USAGE = ` + +
+ +
+ + + + + + +

Bitte akzeptieren Sie Marketing-Cookies.

+
+`; + +// ============================================================================= +// RxJS Observable Wrapper (Optional) +// ============================================================================= + +/** + * RxJS Observable Wrapper fuer ConsentService + * + * Fuer Projekte die RxJS bevorzugen: + * + * @example + * ```typescript + * import { BehaviorSubject, Observable } from 'rxjs'; + * + * export class ConsentServiceRx extends ConsentServiceBase { + * private consentSubject = new BehaviorSubject(null); + * private bannerVisibleSubject = new BehaviorSubject(false); + * + * consent$ = this.consentSubject.asObservable(); + * isBannerVisible$ = this.bannerVisibleSubject.asObservable(); + * + * constructor(config: ConsentConfig) { + * super(config); + * this.onConsentChange((c) => this.consentSubject.next(c)); + * this.onBannerShow(() => this.bannerVisibleSubject.next(true)); + * this.onBannerHide(() => this.bannerVisibleSubject.next(false)); + * } + * } + * ``` + */ + +// ============================================================================= +// Exports +// ============================================================================= + +export type { ConsentConfig, ConsentState, ConsentCategory, ConsentCategories }; diff --git a/consent-sdk/src/core/ConsentAPI.test.ts b/consent-sdk/src/core/ConsentAPI.test.ts new file mode 100644 index 0000000..9fa39eb --- /dev/null +++ b/consent-sdk/src/core/ConsentAPI.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConsentAPI } from './ConsentAPI'; +import type { ConsentConfig, ConsentState } from '../types'; + +describe('ConsentAPI', () => { + let api: ConsentAPI; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com/', + siteId: 'test-site', + debug: false, + }; + + const mockConsent: ConsentState = { + categories: { + essential: true, + functional: true, + analytics: false, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: '2024-01-15T10:00:00.000Z', + version: '1.0.0', + }; + + beforeEach(() => { + api = new ConsentAPI(mockConfig); + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should strip trailing slash from apiEndpoint', () => { + expect(api).toBeDefined(); + }); + }); + + describe('saveConsent', () => { + it('should POST consent to the API', async () => { + const mockResponse = { + consentId: 'consent-123', + timestamp: '2024-01-15T10:00:00.000Z', + expiresAt: '2025-01-15T10:00:00.000Z', + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockResponse), + } as Response); + + const result = await api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/consent', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + credentials: 'include', + }) + ); + + expect(result.consentId).toBe('consent-123'); + }); + + it('should include metadata in the request', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consentId: '123' }), + } as Response); + + await api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + const call = vi.mocked(fetch).mock.calls[0]; + const body = JSON.parse(call[1]?.body as string); + + expect(body.metadata).toBeDefined(); + expect(body.metadata.platform).toBe('web'); + }); + + it('should throw on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + await expect( + api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }) + ).rejects.toThrow('Failed to save consent: 500'); + }); + + it('should include signature headers', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consentId: '123' }), + } as Response); + + await api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + const call = vi.mocked(fetch).mock.calls[0]; + const headers = call[1]?.headers as Record; + + expect(headers['X-Consent-Timestamp']).toBeDefined(); + expect(headers['X-Consent-Signature']).toMatch(/^sha256=/); + }); + }); + + describe('getConsent', () => { + it('should GET consent from the API', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consent: mockConsent }), + } as Response); + + const result = await api.getConsent('test-site', 'fp_123'); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/consent?siteId=test-site&deviceFingerprint=fp_123'), + expect.objectContaining({ + headers: expect.any(Object), + credentials: 'include', + }) + ); + + expect(result?.categories.essential).toBe(true); + }); + + it('should return null on 404', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + const result = await api.getConsent('test-site', 'fp_123'); + + expect(result).toBeNull(); + }); + + it('should throw on other errors', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + await expect(api.getConsent('test-site', 'fp_123')).rejects.toThrow( + 'Failed to get consent: 500' + ); + }); + }); + + describe('revokeConsent', () => { + it('should DELETE consent from the API', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 204, + } as Response); + + await api.revokeConsent('consent-123'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/consent/consent-123', + expect.objectContaining({ + method: 'DELETE', + }) + ); + }); + + it('should throw on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + await expect(api.revokeConsent('consent-123')).rejects.toThrow( + 'Failed to revoke consent: 404' + ); + }); + }); + + describe('getSiteConfig', () => { + it('should GET site configuration', async () => { + const mockSiteConfig = { + siteId: 'test-site', + name: 'Test Site', + categories: ['essential', 'analytics'], + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockSiteConfig), + } as Response); + + const result = await api.getSiteConfig('test-site'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/config/test-site', + expect.any(Object) + ); + + expect(result.siteId).toBe('test-site'); + }); + + it('should throw on error', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + await expect(api.getSiteConfig('unknown-site')).rejects.toThrow( + 'Failed to get site config: 404' + ); + }); + }); + + describe('exportConsent', () => { + it('should GET consent export for user', async () => { + const mockExport = { + userId: 'user-123', + consents: [mockConsent], + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockExport), + } as Response); + + const result = await api.exportConsent('user-123'); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/consent/export?userId=user-123'), + expect.any(Object) + ); + + expect(result).toEqual(mockExport); + }); + + it('should throw on error', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 403, + } as Response); + + await expect(api.exportConsent('user-123')).rejects.toThrow( + 'Failed to export consent: 403' + ); + }); + }); + + describe('network errors', () => { + it('should propagate fetch errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + await expect( + api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }) + ).rejects.toThrow('Network error'); + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugApi = new ConsentAPI({ + ...mockConfig, + debug: true, + }); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consentId: '123' }), + } as Response); + + await debugApi.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/consent-sdk/src/core/ConsentAPI.ts b/consent-sdk/src/core/ConsentAPI.ts new file mode 100644 index 0000000..afc2d9a --- /dev/null +++ b/consent-sdk/src/core/ConsentAPI.ts @@ -0,0 +1,212 @@ +/** + * ConsentAPI - Kommunikation mit dem Consent-Backend + * + * Sendet Consent-Entscheidungen an das Backend zur + * revisionssicheren Speicherung. + */ + +import type { + ConsentConfig, + ConsentState, + ConsentAPIResponse, + SiteConfigResponse, +} from '../types'; + +/** + * Request-Payload fuer Consent-Speicherung + */ +interface SaveConsentRequest { + siteId: string; + userId?: string; + deviceFingerprint: string; + consent: ConsentState; + metadata?: { + userAgent?: string; + language?: string; + screenResolution?: string; + platform?: string; + appVersion?: string; + }; +} + +/** + * ConsentAPI - Backend-Kommunikation + */ +export class ConsentAPI { + private config: ConsentConfig; + private baseUrl: string; + + constructor(config: ConsentConfig) { + this.config = config; + this.baseUrl = config.apiEndpoint.replace(/\/$/, ''); + } + + /** + * Consent speichern + */ + async saveConsent(request: SaveConsentRequest): Promise { + const payload = { + ...request, + metadata: { + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', + language: typeof navigator !== 'undefined' ? navigator.language : '', + screenResolution: + typeof window !== 'undefined' + ? `${window.screen.width}x${window.screen.height}` + : '', + platform: 'web', + ...request.metadata, + }, + }; + + const response = await this.fetch('/consent', { + method: 'POST', + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to save consent: ${response.status}`); + } + + return response.json(); + } + + /** + * Consent abrufen + */ + async getConsent( + siteId: string, + deviceFingerprint: string + ): Promise { + const params = new URLSearchParams({ + siteId, + deviceFingerprint, + }); + + const response = await this.fetch(`/consent?${params}`); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`Failed to get consent: ${response.status}`); + } + + const data = await response.json(); + return data.consent; + } + + /** + * Consent widerrufen + */ + async revokeConsent(consentId: string): Promise { + const response = await this.fetch(`/consent/${consentId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Failed to revoke consent: ${response.status}`); + } + } + + /** + * Site-Konfiguration abrufen + */ + async getSiteConfig(siteId: string): Promise { + const response = await this.fetch(`/config/${siteId}`); + + if (!response.ok) { + throw new Error(`Failed to get site config: ${response.status}`); + } + + return response.json(); + } + + /** + * Consent-Historie exportieren (DSGVO Art. 20) + */ + async exportConsent(userId: string): Promise { + const params = new URLSearchParams({ userId }); + const response = await this.fetch(`/consent/export?${params}`); + + if (!response.ok) { + throw new Error(`Failed to export consent: ${response.status}`); + } + + return response.json(); + } + + // =========================================================================== + // Internal Methods + // =========================================================================== + + /** + * Fetch mit Standard-Headers + */ + private async fetch( + path: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...this.getSignatureHeaders(), + ...(options.headers || {}), + }; + + try { + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', + }); + + this.log(`${options.method || 'GET'} ${path}:`, response.status); + return response; + } catch (error) { + this.log('Fetch error:', error); + throw error; + } + } + + /** + * Signatur-Headers generieren (HMAC) + */ + private getSignatureHeaders(): Record { + const timestamp = Math.floor(Date.now() / 1000).toString(); + + // Einfache Signatur fuer Client-Side + // In Produktion: Server-seitige Validierung mit echtem HMAC + const signature = this.simpleHash(`${this.config.siteId}:${timestamp}`); + + return { + 'X-Consent-Timestamp': timestamp, + 'X-Consent-Signature': `sha256=${signature}`, + }; + } + + /** + * Einfache Hash-Funktion (djb2) + */ + private simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(16); + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ConsentAPI]', ...args); + } + } +} + +export default ConsentAPI; diff --git a/consent-sdk/src/core/ConsentManager.test.ts b/consent-sdk/src/core/ConsentManager.test.ts new file mode 100644 index 0000000..8e7be71 --- /dev/null +++ b/consent-sdk/src/core/ConsentManager.test.ts @@ -0,0 +1,605 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ConsentManager } from './ConsentManager'; +import type { ConsentConfig, ConsentState } from '../types'; + +describe('ConsentManager', () => { + let manager: ConsentManager; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com', + siteId: 'test-site', + debug: false, + }; + + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + + // Mock successful API response + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + consentId: 'consent-123', + timestamp: '2024-01-15T10:00:00.000Z', + expiresAt: '2025-01-15T10:00:00.000Z', + }), + } as Response); + + manager = new ConsentManager(mockConfig); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create manager with merged config', () => { + expect(manager).toBeDefined(); + }); + + it('should apply default config values', () => { + // Default consent config should be applied + expect(manager).toBeDefined(); + }); + }); + + describe('init', () => { + it('should initialize the manager', async () => { + await manager.init(); + + // Should have generated fingerprint and be initialized + expect(manager.needsConsent()).toBe(true); // No consent stored + }); + + it('should only initialize once', async () => { + await manager.init(); + await manager.init(); // Second call should be skipped + + expect(manager.needsConsent()).toBe(true); + }); + + it('should emit init event', async () => { + const callback = vi.fn(); + manager.on('init', callback); + + await manager.init(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should load existing consent from storage', async () => { + // Pre-set consent in storage + const storageKey = `bp_consent_${mockConfig.siteId}`; + const mockConsent = { + categories: { + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: new Date().toISOString(), + version: '1.0.0', + }; + + // Create a simple hash for signature + const data = JSON.stringify(mockConsent) + mockConfig.siteId; + let hash = 5381; + for (let i = 0; i < data.length; i++) { + hash = (hash * 33) ^ data.charCodeAt(i); + } + const signature = (hash >>> 0).toString(16); + + localStorage.setItem( + storageKey, + JSON.stringify({ + version: '1', + consent: mockConsent, + signature, + }) + ); + + manager = new ConsentManager(mockConfig); + await manager.init(); + + expect(manager.hasConsent('analytics')).toBe(true); + }); + + it('should show banner when no consent exists', async () => { + const callback = vi.fn(); + manager.on('banner_show', callback); + + await manager.init(); + + expect(callback).toHaveBeenCalled(); + expect(manager.isBannerVisible()).toBe(true); + }); + }); + + describe('hasConsent', () => { + it('should return true for essential without initialization', () => { + expect(manager.hasConsent('essential')).toBe(true); + }); + + it('should return false for other categories without consent', () => { + expect(manager.hasConsent('analytics')).toBe(false); + expect(manager.hasConsent('marketing')).toBe(false); + }); + }); + + describe('hasVendorConsent', () => { + it('should return false when no consent exists', () => { + expect(manager.hasVendorConsent('google-analytics')).toBe(false); + }); + }); + + describe('getConsent', () => { + it('should return null when no consent exists', () => { + expect(manager.getConsent()).toBeNull(); + }); + + it('should return a copy of consent state', async () => { + await manager.init(); + await manager.acceptAll(); + + const consent1 = manager.getConsent(); + const consent2 = manager.getConsent(); + + expect(consent1).not.toBe(consent2); // Different objects + expect(consent1).toEqual(consent2); // Same content + }); + }); + + describe('setConsent', () => { + it('should set consent categories', async () => { + await manager.init(); + await manager.setConsent({ + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('analytics')).toBe(true); + expect(manager.hasConsent('marketing')).toBe(false); + }); + + it('should always keep essential enabled', async () => { + await manager.init(); + await manager.setConsent({ + essential: false, // Attempting to disable + functional: false, + analytics: false, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('essential')).toBe(true); + }); + + it('should emit change event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('change', callback); + + await manager.setConsent({ + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }); + + expect(callback).toHaveBeenCalled(); + }); + + it('should save consent locally even on API error', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + await manager.init(); + await manager.setConsent({ + essential: true, + functional: false, + analytics: true, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('analytics')).toBe(true); + }); + }); + + describe('acceptAll', () => { + it('should enable all categories', async () => { + await manager.init(); + await manager.acceptAll(); + + expect(manager.hasConsent('essential')).toBe(true); + expect(manager.hasConsent('functional')).toBe(true); + expect(manager.hasConsent('analytics')).toBe(true); + expect(manager.hasConsent('marketing')).toBe(true); + expect(manager.hasConsent('social')).toBe(true); + }); + + it('should emit accept_all event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('accept_all', callback); + + await manager.acceptAll(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should hide banner', async () => { + await manager.init(); + expect(manager.isBannerVisible()).toBe(true); + + await manager.acceptAll(); + expect(manager.isBannerVisible()).toBe(false); + }); + }); + + describe('rejectAll', () => { + it('should only keep essential enabled', async () => { + await manager.init(); + await manager.rejectAll(); + + expect(manager.hasConsent('essential')).toBe(true); + expect(manager.hasConsent('functional')).toBe(false); + expect(manager.hasConsent('analytics')).toBe(false); + expect(manager.hasConsent('marketing')).toBe(false); + expect(manager.hasConsent('social')).toBe(false); + }); + + it('should emit reject_all event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('reject_all', callback); + + await manager.rejectAll(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should hide banner', async () => { + await manager.init(); + await manager.rejectAll(); + + expect(manager.isBannerVisible()).toBe(false); + }); + }); + + describe('revokeAll', () => { + it('should clear all consent', async () => { + await manager.init(); + await manager.acceptAll(); + await manager.revokeAll(); + + expect(manager.getConsent()).toBeNull(); + }); + + it('should try to revoke on server', async () => { + await manager.init(); + await manager.acceptAll(); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 204, + } as Response); + + await manager.revokeAll(); + + // DELETE request should have been made + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/consent/'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + }); + + describe('exportConsent', () => { + it('should export consent data as JSON', async () => { + await manager.init(); + await manager.acceptAll(); + + const exported = await manager.exportConsent(); + const parsed = JSON.parse(exported); + + expect(parsed.currentConsent).toBeDefined(); + expect(parsed.exportedAt).toBeDefined(); + expect(parsed.siteId).toBe('test-site'); + }); + }); + + describe('needsConsent', () => { + it('should return true when no consent exists', () => { + expect(manager.needsConsent()).toBe(true); + }); + + it('should return false when valid consent exists', async () => { + await manager.init(); + await manager.acceptAll(); + + // After acceptAll, consent should exist + expect(manager.getConsent()).not.toBeNull(); + // needsConsent checks for currentConsent and expiration + // Since we just accepted all, consent should be valid + const consent = manager.getConsent(); + expect(consent?.categories?.essential).toBe(true); + }); + }); + + describe('banner control', () => { + it('should show banner', async () => { + await manager.init(); + manager.hideBanner(); + manager.showBanner(); + + expect(manager.isBannerVisible()).toBe(true); + }); + + it('should hide banner', async () => { + await manager.init(); + manager.hideBanner(); + + expect(manager.isBannerVisible()).toBe(false); + }); + + it('should emit banner_show event', async () => { + const callback = vi.fn(); + manager.on('banner_show', callback); + + await manager.init(); // This shows banner + + expect(callback).toHaveBeenCalled(); + }); + + it('should emit banner_hide event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('banner_hide', callback); + + manager.hideBanner(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should not show banner if already visible', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('banner_show', callback); + + callback.mockClear(); + manager.showBanner(); + manager.showBanner(); + + expect(callback).toHaveBeenCalledTimes(0); // Already visible from init + }); + }); + + describe('showSettings', () => { + it('should emit settings_open event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('settings_open', callback); + + manager.showSettings(); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('event handling', () => { + it('should register event listeners', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('change', callback); + + await manager.acceptAll(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should unregister event listeners', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('change', callback); + manager.off('change', callback); + + await manager.acceptAll(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should return unsubscribe function', async () => { + await manager.init(); + const callback = vi.fn(); + const unsubscribe = manager.on('change', callback); + + unsubscribe(); + await manager.acceptAll(); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('callbacks', () => { + it('should call onConsentChange callback', async () => { + const onConsentChange = vi.fn(); + manager = new ConsentManager({ + ...mockConfig, + onConsentChange, + }); + + await manager.init(); + await manager.acceptAll(); + + expect(onConsentChange).toHaveBeenCalled(); + }); + + it('should call onBannerShow callback', async () => { + const onBannerShow = vi.fn(); + manager = new ConsentManager({ + ...mockConfig, + onBannerShow, + }); + + await manager.init(); + + expect(onBannerShow).toHaveBeenCalled(); + }); + + it('should call onBannerHide callback', async () => { + const onBannerHide = vi.fn(); + manager = new ConsentManager({ + ...mockConfig, + onBannerHide, + }); + + await manager.init(); + manager.hideBanner(); + + expect(onBannerHide).toHaveBeenCalled(); + }); + }); + + describe('static methods', () => { + it('should return SDK version', () => { + const version = ConsentManager.getVersion(); + + expect(version).toBeDefined(); + expect(typeof version).toBe('string'); + }); + }); + + describe('Google Consent Mode', () => { + it('should update Google Consent Mode when gtag is available', async () => { + const gtag = vi.fn(); + (window as unknown as { gtag: typeof gtag }).gtag = gtag; + + await manager.init(); + await manager.acceptAll(); + + expect(gtag).toHaveBeenCalledWith( + 'consent', + 'update', + expect.objectContaining({ + analytics_storage: 'granted', + ad_storage: 'granted', + }) + ); + + delete (window as unknown as { gtag?: typeof gtag }).gtag; + }); + }); + + describe('consent expiration', () => { + it('should clear expired consent on init', async () => { + const storageKey = `bp_consent_${mockConfig.siteId}`; + const expiredConsent = { + categories: { + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: '2020-01-01T00:00:00.000Z', // Very old + version: '1.0.0', + expiresAt: '2020-06-01T00:00:00.000Z', // Expired + }; + + const data = JSON.stringify(expiredConsent) + mockConfig.siteId; + let hash = 5381; + for (let i = 0; i < data.length; i++) { + hash = (hash * 33) ^ data.charCodeAt(i); + } + const signature = (hash >>> 0).toString(16); + + localStorage.setItem( + storageKey, + JSON.stringify({ + version: '1', + consent: expiredConsent, + signature, + }) + ); + + manager = new ConsentManager(mockConfig); + await manager.init(); + + expect(manager.needsConsent()).toBe(true); + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugManager = new ConsentManager({ + ...mockConfig, + debug: true, + }); + + await debugManager.init(); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('consent input normalization', () => { + it('should accept categories object directly', async () => { + await manager.init(); + await manager.setConsent({ + essential: true, + functional: true, + analytics: false, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('functional')).toBe(true); + }); + + it('should accept nested categories object', async () => { + await manager.init(); + await manager.setConsent({ + categories: { + essential: true, + functional: false, + analytics: true, + marketing: false, + social: false, + }, + }); + + expect(manager.hasConsent('analytics')).toBe(true); + expect(manager.hasConsent('functional')).toBe(false); + }); + + it('should accept vendors in consent input', async () => { + await manager.init(); + await manager.setConsent({ + categories: { + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }, + vendors: { + 'google-analytics': true, + }, + }); + + const consent = manager.getConsent(); + expect(consent?.vendors['google-analytics']).toBe(true); + }); + }); +}); diff --git a/consent-sdk/src/core/ConsentManager.ts b/consent-sdk/src/core/ConsentManager.ts new file mode 100644 index 0000000..be203d9 --- /dev/null +++ b/consent-sdk/src/core/ConsentManager.ts @@ -0,0 +1,525 @@ +/** + * ConsentManager - Hauptklasse fuer das Consent Management + * + * DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile. + */ + +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, + ConsentInput, + ConsentEventType, + ConsentEventCallback, + ConsentEventData, +} from '../types'; +import { ConsentStorage } from './ConsentStorage'; +import { ScriptBlocker } from './ScriptBlocker'; +import { ConsentAPI } from './ConsentAPI'; +import { EventEmitter } from '../utils/EventEmitter'; +import { generateFingerprint } from '../utils/fingerprint'; +import { SDK_VERSION } from '../version'; + +/** + * Default-Konfiguration + */ +const DEFAULT_CONFIG: Partial = { + language: 'de', + fallbackLanguage: 'en', + ui: { + position: 'bottom', + layout: 'modal', + theme: 'auto', + zIndex: 999999, + blockScrollOnModal: true, + }, + consent: { + required: true, + rejectAllVisible: true, + acceptAllVisible: true, + granularControl: true, + vendorControl: false, + rememberChoice: true, + rememberDays: 365, + geoTargeting: false, + recheckAfterDays: 180, + }, + categories: ['essential', 'functional', 'analytics', 'marketing', 'social'], + debug: false, +}; + +/** + * Default Consent-State (nur Essential aktiv) + */ +const DEFAULT_CONSENT: ConsentCategories = { + essential: true, + functional: false, + analytics: false, + marketing: false, + social: false, +}; + +/** + * ConsentManager - Zentrale Klasse fuer Consent-Verwaltung + */ +export class ConsentManager { + private config: ConsentConfig; + private storage: ConsentStorage; + private scriptBlocker: ScriptBlocker; + private api: ConsentAPI; + private events: EventEmitter; + private currentConsent: ConsentState | null = null; + private initialized = false; + private bannerVisible = false; + private deviceFingerprint: string = ''; + + constructor(config: ConsentConfig) { + this.config = this.mergeConfig(config); + this.storage = new ConsentStorage(this.config); + this.scriptBlocker = new ScriptBlocker(this.config); + this.api = new ConsentAPI(this.config); + this.events = new EventEmitter(); + + this.log('ConsentManager created with config:', this.config); + } + + /** + * SDK initialisieren + */ + async init(): Promise { + if (this.initialized) { + this.log('Already initialized, skipping'); + return; + } + + try { + this.log('Initializing ConsentManager...'); + + // Device Fingerprint generieren + this.deviceFingerprint = await generateFingerprint(); + + // Consent aus Storage laden + this.currentConsent = this.storage.get(); + + if (this.currentConsent) { + this.log('Loaded consent from storage:', this.currentConsent); + + // Pruefen ob Consent abgelaufen + if (this.isConsentExpired()) { + this.log('Consent expired, clearing'); + this.storage.clear(); + this.currentConsent = null; + } else { + // Consent anwenden + this.applyConsent(); + } + } + + // Script-Blocker initialisieren + this.scriptBlocker.init(); + + this.initialized = true; + this.emit('init', this.currentConsent); + + // Banner anzeigen falls noetig + if (this.needsConsent()) { + this.showBanner(); + } + + this.log('ConsentManager initialized successfully'); + } catch (error) { + this.handleError(error as Error); + throw error; + } + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Pruefen ob Consent fuer Kategorie vorhanden + */ + hasConsent(category: ConsentCategory): boolean { + if (!this.currentConsent) { + return category === 'essential'; + } + return this.currentConsent.categories[category] ?? false; + } + + /** + * Pruefen ob Consent fuer Vendor vorhanden + */ + hasVendorConsent(vendorId: string): boolean { + if (!this.currentConsent) { + return false; + } + return this.currentConsent.vendors[vendorId] ?? false; + } + + /** + * Aktuellen Consent-State abrufen + */ + getConsent(): ConsentState | null { + return this.currentConsent ? { ...this.currentConsent } : null; + } + + /** + * Consent setzen + */ + async setConsent(input: ConsentInput): Promise { + const categories = this.normalizeConsentInput(input); + + // Essential ist immer aktiv + categories.essential = true; + + const newConsent: ConsentState = { + categories, + vendors: 'vendors' in input && input.vendors ? input.vendors : {}, + timestamp: new Date().toISOString(), + version: SDK_VERSION, + }; + + try { + // An Backend senden + const response = await this.api.saveConsent({ + siteId: this.config.siteId, + deviceFingerprint: this.deviceFingerprint, + consent: newConsent, + }); + + newConsent.consentId = response.consentId; + newConsent.expiresAt = response.expiresAt; + + // Lokal speichern + this.storage.set(newConsent); + this.currentConsent = newConsent; + + // Consent anwenden + this.applyConsent(); + + // Event emittieren + this.emit('change', newConsent); + this.config.onConsentChange?.(newConsent); + + this.log('Consent saved:', newConsent); + } catch (error) { + // Bei Netzwerkfehler trotzdem lokal speichern + this.log('API error, saving locally:', error); + this.storage.set(newConsent); + this.currentConsent = newConsent; + this.applyConsent(); + this.emit('change', newConsent); + } + } + + /** + * Alle Kategorien akzeptieren + */ + async acceptAll(): Promise { + const allCategories: ConsentCategories = { + essential: true, + functional: true, + analytics: true, + marketing: true, + social: true, + }; + + await this.setConsent(allCategories); + this.emit('accept_all', this.currentConsent!); + this.hideBanner(); + } + + /** + * Alle nicht-essentiellen Kategorien ablehnen + */ + async rejectAll(): Promise { + const minimalCategories: ConsentCategories = { + essential: true, + functional: false, + analytics: false, + marketing: false, + social: false, + }; + + await this.setConsent(minimalCategories); + this.emit('reject_all', this.currentConsent!); + this.hideBanner(); + } + + /** + * Alle Einwilligungen widerrufen + */ + async revokeAll(): Promise { + if (this.currentConsent?.consentId) { + try { + await this.api.revokeConsent(this.currentConsent.consentId); + } catch (error) { + this.log('Failed to revoke on server:', error); + } + } + + this.storage.clear(); + this.currentConsent = null; + this.scriptBlocker.blockAll(); + + this.log('All consents revoked'); + } + + /** + * Consent-Daten exportieren (DSGVO Art. 20) + */ + async exportConsent(): Promise { + const exportData = { + currentConsent: this.currentConsent, + exportedAt: new Date().toISOString(), + siteId: this.config.siteId, + deviceFingerprint: this.deviceFingerprint, + }; + + return JSON.stringify(exportData, null, 2); + } + + // =========================================================================== + // Banner Control + // =========================================================================== + + /** + * Pruefen ob Consent-Abfrage noetig + */ + needsConsent(): boolean { + if (!this.currentConsent) { + return true; + } + + if (this.isConsentExpired()) { + return true; + } + + // Recheck nach X Tagen + if (this.config.consent?.recheckAfterDays) { + const consentDate = new Date(this.currentConsent.timestamp); + const recheckDate = new Date(consentDate); + recheckDate.setDate( + recheckDate.getDate() + this.config.consent.recheckAfterDays + ); + + if (new Date() > recheckDate) { + return true; + } + } + + return false; + } + + /** + * Banner anzeigen + */ + showBanner(): void { + if (this.bannerVisible) { + return; + } + + this.bannerVisible = true; + this.emit('banner_show', undefined); + this.config.onBannerShow?.(); + + // Banner wird von UI-Komponente gerendert + // Hier nur Status setzen + this.log('Banner shown'); + } + + /** + * Banner verstecken + */ + hideBanner(): void { + if (!this.bannerVisible) { + return; + } + + this.bannerVisible = false; + this.emit('banner_hide', undefined); + this.config.onBannerHide?.(); + + this.log('Banner hidden'); + } + + /** + * Einstellungs-Modal oeffnen + */ + showSettings(): void { + this.emit('settings_open', undefined); + this.log('Settings opened'); + } + + /** + * Pruefen ob Banner sichtbar + */ + isBannerVisible(): boolean { + return this.bannerVisible; + } + + // =========================================================================== + // Event Handling + // =========================================================================== + + /** + * Event-Listener registrieren + */ + on( + event: T, + callback: ConsentEventCallback + ): () => void { + return this.events.on(event, callback); + } + + /** + * Event-Listener entfernen + */ + off( + event: T, + callback: ConsentEventCallback + ): void { + this.events.off(event, callback); + } + + // =========================================================================== + // Internal Methods + // =========================================================================== + + /** + * Konfiguration zusammenfuehren + */ + private mergeConfig(config: ConsentConfig): ConsentConfig { + return { + ...DEFAULT_CONFIG, + ...config, + ui: { ...DEFAULT_CONFIG.ui, ...config.ui }, + consent: { ...DEFAULT_CONFIG.consent, ...config.consent }, + } as ConsentConfig; + } + + /** + * Consent-Input normalisieren + */ + private normalizeConsentInput(input: ConsentInput): ConsentCategories { + if ('categories' in input && input.categories) { + return { ...DEFAULT_CONSENT, ...input.categories }; + } + + return { ...DEFAULT_CONSENT, ...(input as Partial) }; + } + + /** + * Consent anwenden (Skripte aktivieren/blockieren) + */ + private applyConsent(): void { + if (!this.currentConsent) { + return; + } + + for (const [category, allowed] of Object.entries( + this.currentConsent.categories + )) { + if (allowed) { + this.scriptBlocker.enableCategory(category as ConsentCategory); + } else { + this.scriptBlocker.disableCategory(category as ConsentCategory); + } + } + + // Google Consent Mode aktualisieren + this.updateGoogleConsentMode(); + } + + /** + * Google Consent Mode v2 aktualisieren + */ + private updateGoogleConsentMode(): void { + if (typeof window === 'undefined' || !this.currentConsent) { + return; + } + + const gtag = (window as unknown as { gtag?: (...args: unknown[]) => void }).gtag; + if (typeof gtag !== 'function') { + return; + } + + const { categories } = this.currentConsent; + + gtag('consent', 'update', { + ad_storage: categories.marketing ? 'granted' : 'denied', + ad_user_data: categories.marketing ? 'granted' : 'denied', + ad_personalization: categories.marketing ? 'granted' : 'denied', + analytics_storage: categories.analytics ? 'granted' : 'denied', + functionality_storage: categories.functional ? 'granted' : 'denied', + personalization_storage: categories.functional ? 'granted' : 'denied', + security_storage: 'granted', + }); + + this.log('Google Consent Mode updated'); + } + + /** + * Pruefen ob Consent abgelaufen + */ + private isConsentExpired(): boolean { + if (!this.currentConsent?.expiresAt) { + // Fallback: Nach rememberDays ablaufen + if (this.currentConsent?.timestamp && this.config.consent?.rememberDays) { + const consentDate = new Date(this.currentConsent.timestamp); + const expiryDate = new Date(consentDate); + expiryDate.setDate( + expiryDate.getDate() + this.config.consent.rememberDays + ); + return new Date() > expiryDate; + } + return false; + } + + return new Date() > new Date(this.currentConsent.expiresAt); + } + + /** + * Event emittieren + */ + private emit( + event: T, + data: ConsentEventData[T] + ): void { + this.events.emit(event, data); + } + + /** + * Fehler behandeln + */ + private handleError(error: Error): void { + this.log('Error:', error); + this.emit('error', error); + this.config.onError?.(error); + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ConsentSDK]', ...args); + } + } + + // =========================================================================== + // Static Methods + // =========================================================================== + + /** + * SDK-Version abrufen + */ + static getVersion(): string { + return SDK_VERSION; + } +} + +// Default-Export +export default ConsentManager; diff --git a/consent-sdk/src/core/ConsentStorage.test.ts b/consent-sdk/src/core/ConsentStorage.test.ts new file mode 100644 index 0000000..a00871a --- /dev/null +++ b/consent-sdk/src/core/ConsentStorage.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConsentStorage } from './ConsentStorage'; +import type { ConsentConfig, ConsentState } from '../types'; + +describe('ConsentStorage', () => { + let storage: ConsentStorage; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com', + siteId: 'test-site', + debug: false, + consent: { + rememberDays: 365, + }, + }; + + const mockConsent: ConsentState = { + categories: { + essential: true, + functional: true, + analytics: false, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: '2024-01-15T10:00:00.000Z', + version: '1.0.0', + }; + + beforeEach(() => { + localStorage.clear(); + storage = new ConsentStorage(mockConfig); + }); + + describe('constructor', () => { + it('should create storage with site-specific key', () => { + expect(storage).toBeDefined(); + }); + }); + + describe('get', () => { + it('should return null when no consent stored', () => { + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should return consent when valid data exists', () => { + storage.set(mockConsent); + const result = storage.get(); + + expect(result).toBeDefined(); + expect(result?.categories.essential).toBe(true); + expect(result?.categories.analytics).toBe(false); + }); + + it('should return null and clear when version mismatch', () => { + // Manually set invalid version in storage + const storageKey = `bp_consent_${mockConfig.siteId}`; + localStorage.setItem( + storageKey, + JSON.stringify({ + version: 'invalid', + consent: mockConsent, + signature: 'test', + }) + ); + + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should return null and clear when signature invalid', () => { + const storageKey = `bp_consent_${mockConfig.siteId}`; + localStorage.setItem( + storageKey, + JSON.stringify({ + version: '1', + consent: mockConsent, + signature: 'invalid-signature', + }) + ); + + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should return null when JSON is invalid', () => { + const storageKey = `bp_consent_${mockConfig.siteId}`; + localStorage.setItem(storageKey, 'invalid-json'); + + const result = storage.get(); + expect(result).toBeNull(); + }); + }); + + describe('set', () => { + it('should store consent in localStorage', () => { + storage.set(mockConsent); + + const storageKey = `bp_consent_${mockConfig.siteId}`; + const stored = localStorage.getItem(storageKey); + + expect(stored).toBeDefined(); + expect(stored).toContain('"version":"1"'); + }); + + it('should set a cookie for SSR support', () => { + storage.set(mockConsent); + + expect(document.cookie).toContain(`bp_consent_${mockConfig.siteId}`); + }); + + it('should include signature in stored data', () => { + storage.set(mockConsent); + + const storageKey = `bp_consent_${mockConfig.siteId}`; + const stored = JSON.parse(localStorage.getItem(storageKey) || '{}'); + + expect(stored.signature).toBeDefined(); + expect(typeof stored.signature).toBe('string'); + }); + }); + + describe('clear', () => { + it('should remove consent from localStorage', () => { + storage.set(mockConsent); + storage.clear(); + + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should clear the cookie', () => { + storage.set(mockConsent); + storage.clear(); + + // Cookie should be cleared (expired) + expect(document.cookie).toContain('expires='); + }); + }); + + describe('exists', () => { + it('should return false when no consent exists', () => { + expect(storage.exists()).toBe(false); + }); + + it('should return true when consent exists', () => { + storage.set(mockConsent); + expect(storage.exists()).toBe(true); + }); + }); + + describe('signature verification', () => { + it('should detect tampered consent data', () => { + storage.set(mockConsent); + + const storageKey = `bp_consent_${mockConfig.siteId}`; + const stored = JSON.parse(localStorage.getItem(storageKey) || '{}'); + + // Tamper with the data + stored.consent.categories.marketing = true; + localStorage.setItem(storageKey, JSON.stringify(stored)); + + const result = storage.get(); + expect(result).toBeNull(); // Signature mismatch should clear + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugStorage = new ConsentStorage({ + ...mockConfig, + debug: true, + }); + + debugStorage.set(mockConsent); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('cookie settings', () => { + it('should set Secure flag on HTTPS', () => { + storage.set(mockConsent); + expect(document.cookie).toContain('Secure'); + }); + + it('should set SameSite=Lax', () => { + storage.set(mockConsent); + expect(document.cookie).toContain('SameSite=Lax'); + }); + + it('should set expiration based on rememberDays', () => { + storage.set(mockConsent); + expect(document.cookie).toContain('expires='); + }); + }); + + describe('different sites', () => { + it('should isolate storage by siteId', () => { + const storage1 = new ConsentStorage({ ...mockConfig, siteId: 'site-1' }); + const storage2 = new ConsentStorage({ ...mockConfig, siteId: 'site-2' }); + + storage1.set(mockConsent); + + expect(storage1.exists()).toBe(true); + expect(storage2.exists()).toBe(false); + }); + }); +}); diff --git a/consent-sdk/src/core/ConsentStorage.ts b/consent-sdk/src/core/ConsentStorage.ts new file mode 100644 index 0000000..f91b0df --- /dev/null +++ b/consent-sdk/src/core/ConsentStorage.ts @@ -0,0 +1,203 @@ +/** + * ConsentStorage - Lokale Speicherung des Consent-Status + * + * Speichert Consent-Daten im localStorage mit HMAC-Signatur + * zur Manipulationserkennung. + */ + +import type { ConsentConfig, ConsentState } from '../types'; + +const STORAGE_KEY = 'bp_consent'; +const STORAGE_VERSION = '1'; + +/** + * Gespeichertes Format + */ +interface StoredConsent { + version: string; + consent: ConsentState; + signature: string; +} + +/** + * ConsentStorage - Persistente Speicherung + */ +export class ConsentStorage { + private config: ConsentConfig; + private storageKey: string; + + constructor(config: ConsentConfig) { + this.config = config; + // Pro Site ein separater Key + this.storageKey = `${STORAGE_KEY}_${config.siteId}`; + } + + /** + * Consent laden + */ + get(): ConsentState | null { + if (typeof window === 'undefined') { + return null; + } + + try { + const raw = localStorage.getItem(this.storageKey); + if (!raw) { + return null; + } + + const stored: StoredConsent = JSON.parse(raw); + + // Version pruefen + if (stored.version !== STORAGE_VERSION) { + this.log('Storage version mismatch, clearing'); + this.clear(); + return null; + } + + // Signatur pruefen + if (!this.verifySignature(stored.consent, stored.signature)) { + this.log('Invalid signature, clearing'); + this.clear(); + return null; + } + + return stored.consent; + } catch (error) { + this.log('Failed to load consent:', error); + return null; + } + } + + /** + * Consent speichern + */ + set(consent: ConsentState): void { + if (typeof window === 'undefined') { + return; + } + + try { + const signature = this.generateSignature(consent); + + const stored: StoredConsent = { + version: STORAGE_VERSION, + consent, + signature, + }; + + localStorage.setItem(this.storageKey, JSON.stringify(stored)); + + // Auch als Cookie setzen (fuer Server-Side Rendering) + this.setCookie(consent); + + this.log('Consent saved to storage'); + } catch (error) { + this.log('Failed to save consent:', error); + } + } + + /** + * Consent loeschen + */ + clear(): void { + if (typeof window === 'undefined') { + return; + } + + try { + localStorage.removeItem(this.storageKey); + this.clearCookie(); + this.log('Consent cleared from storage'); + } catch (error) { + this.log('Failed to clear consent:', error); + } + } + + /** + * Pruefen ob Consent existiert + */ + exists(): boolean { + return this.get() !== null; + } + + // =========================================================================== + // Cookie Management + // =========================================================================== + + /** + * Consent als Cookie setzen + */ + private setCookie(consent: ConsentState): void { + const days = this.config.consent?.rememberDays ?? 365; + const expires = new Date(); + expires.setDate(expires.getDate() + days); + + // Nur Kategorien als Cookie (fuer SSR) + const cookieValue = JSON.stringify(consent.categories); + const encoded = encodeURIComponent(cookieValue); + + document.cookie = [ + `${this.storageKey}=${encoded}`, + `expires=${expires.toUTCString()}`, + 'path=/', + 'SameSite=Lax', + location.protocol === 'https:' ? 'Secure' : '', + ] + .filter(Boolean) + .join('; '); + } + + /** + * Cookie loeschen + */ + private clearCookie(): void { + document.cookie = `${this.storageKey}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + } + + // =========================================================================== + // Signature (Simple HMAC-like) + // =========================================================================== + + /** + * Signatur generieren + */ + private generateSignature(consent: ConsentState): string { + const data = JSON.stringify(consent); + const key = this.config.siteId; + + // Einfache Hash-Funktion (fuer Client-Side) + // In Produktion wuerde man SubtleCrypto verwenden + return this.simpleHash(data + key); + } + + /** + * Signatur verifizieren + */ + private verifySignature(consent: ConsentState, signature: string): boolean { + const expected = this.generateSignature(consent); + return expected === signature; + } + + /** + * Einfache Hash-Funktion (djb2) + */ + private simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(16); + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ConsentStorage]', ...args); + } + } +} + +export default ConsentStorage; diff --git a/consent-sdk/src/core/ScriptBlocker.test.ts b/consent-sdk/src/core/ScriptBlocker.test.ts new file mode 100644 index 0000000..d7f2dfc --- /dev/null +++ b/consent-sdk/src/core/ScriptBlocker.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ScriptBlocker } from './ScriptBlocker'; +import type { ConsentConfig } from '../types'; + +describe('ScriptBlocker', () => { + let blocker: ScriptBlocker; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com', + siteId: 'test-site', + debug: false, + }; + + beforeEach(() => { + // Clear document body + document.body.innerHTML = ''; + blocker = new ScriptBlocker(mockConfig); + }); + + afterEach(() => { + blocker.destroy(); + }); + + describe('constructor', () => { + it('should create blocker with essential category enabled by default', () => { + expect(blocker.isCategoryEnabled('essential')).toBe(true); + }); + + it('should have other categories disabled by default', () => { + expect(blocker.isCategoryEnabled('analytics')).toBe(false); + expect(blocker.isCategoryEnabled('marketing')).toBe(false); + expect(blocker.isCategoryEnabled('functional')).toBe(false); + expect(blocker.isCategoryEnabled('social')).toBe(false); + }); + }); + + describe('init', () => { + it('should process existing scripts with data-consent', () => { + // Add a blocked script before init + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + script.setAttribute('data-src', 'https://analytics.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + blocker.init(); + + // Script should remain blocked (analytics not enabled) + expect(script.type).toBe('text/plain'); + }); + + it('should start MutationObserver for new elements', () => { + blocker.init(); + + // Add a script after init + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + script.setAttribute('data-src', 'https://analytics.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + // Script should be tracked (processed) + expect(script.type).toBe('text/plain'); + }); + }); + + describe('enableCategory', () => { + it('should enable a category', () => { + blocker.enableCategory('analytics'); + + expect(blocker.isCategoryEnabled('analytics')).toBe(true); + }); + + it('should not duplicate enabling', () => { + blocker.enableCategory('analytics'); + blocker.enableCategory('analytics'); + + expect(blocker.isCategoryEnabled('analytics')).toBe(true); + }); + + it('should activate blocked scripts for the category', () => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + script.setAttribute('data-src', 'https://analytics.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + blocker.init(); + blocker.enableCategory('analytics'); + + // The original script should be replaced + const scripts = document.querySelectorAll('script[src="https://analytics.example.com/script.js"]'); + expect(scripts.length).toBe(1); + }); + }); + + describe('disableCategory', () => { + it('should disable a category', () => { + blocker.enableCategory('analytics'); + blocker.disableCategory('analytics'); + + expect(blocker.isCategoryEnabled('analytics')).toBe(false); + }); + + it('should not disable essential category', () => { + blocker.disableCategory('essential'); + + expect(blocker.isCategoryEnabled('essential')).toBe(true); + }); + }); + + describe('blockAll', () => { + it('should block all categories except essential', () => { + blocker.enableCategory('analytics'); + blocker.enableCategory('marketing'); + blocker.enableCategory('social'); + + blocker.blockAll(); + + expect(blocker.isCategoryEnabled('essential')).toBe(true); + expect(blocker.isCategoryEnabled('analytics')).toBe(false); + expect(blocker.isCategoryEnabled('marketing')).toBe(false); + expect(blocker.isCategoryEnabled('social')).toBe(false); + }); + }); + + describe('isCategoryEnabled', () => { + it('should return true for enabled categories', () => { + blocker.enableCategory('functional'); + expect(blocker.isCategoryEnabled('functional')).toBe(true); + }); + + it('should return false for disabled categories', () => { + expect(blocker.isCategoryEnabled('marketing')).toBe(false); + }); + }); + + describe('destroy', () => { + it('should disconnect the MutationObserver', () => { + blocker.init(); + blocker.destroy(); + + // After destroy, adding new elements should not trigger processing + // This is hard to test directly, but we can verify it doesn't throw + expect(() => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + document.body.appendChild(script); + }).not.toThrow(); + }); + }); + + describe('script processing', () => { + it('should handle external scripts with data-src', () => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'essential'); + script.setAttribute('data-src', 'https://essential.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + blocker.init(); + + // Essential is enabled, so script should be activated + const activatedScript = document.querySelector('script[src="https://essential.example.com/script.js"]'); + expect(activatedScript).toBeDefined(); + }); + + it('should handle inline scripts', () => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'essential'); + script.type = 'text/plain'; + script.textContent = 'console.log("test");'; + document.body.appendChild(script); + + blocker.init(); + + // Check that script was processed + const scripts = document.querySelectorAll('script'); + expect(scripts.length).toBeGreaterThan(0); + }); + }); + + describe('iframe processing', () => { + it('should block iframes with data-consent', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + // Should be hidden and have placeholder + expect(iframe.style.display).toBe('none'); + }); + + it('should show placeholder for blocked iframes', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + const placeholder = document.querySelector('.bp-consent-placeholder'); + expect(placeholder).toBeDefined(); + }); + + it('should activate iframe when category enabled', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + blocker.enableCategory('social'); + + expect(iframe.src).toBe('https://social.example.com/embed'); + expect(iframe.style.display).toBe(''); + }); + + it('should remove placeholder when iframe activated', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + blocker.enableCategory('social'); + + const placeholder = document.querySelector('.bp-consent-placeholder'); + expect(placeholder).toBeNull(); + }); + }); + + describe('placeholder button', () => { + it('should dispatch event when placeholder button clicked', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'marketing'); + iframe.setAttribute('data-src', 'https://marketing.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + const eventHandler = vi.fn(); + window.addEventListener('bp-consent-request', eventHandler); + + const button = document.querySelector('.bp-consent-placeholder-btn') as HTMLButtonElement; + button?.click(); + + expect(eventHandler).toHaveBeenCalled(); + expect(eventHandler.mock.calls[0][0].detail.category).toBe('marketing'); + + window.removeEventListener('bp-consent-request', eventHandler); + }); + }); + + describe('category names', () => { + it('should show correct category name in placeholder', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'analytics'); + iframe.setAttribute('data-src', 'https://analytics.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + const placeholder = document.querySelector('.bp-consent-placeholder'); + expect(placeholder?.innerHTML).toContain('Statistik-Cookies aktivieren'); + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugBlocker = new ScriptBlocker({ + ...mockConfig, + debug: true, + }); + + debugBlocker.init(); + debugBlocker.enableCategory('analytics'); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + debugBlocker.destroy(); + }); + }); + + describe('nested elements', () => { + it('should process scripts in nested containers', () => { + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('data-consent', 'essential'); + script.setAttribute('data-src', 'https://essential.example.com/nested.js'); + script.type = 'text/plain'; + container.appendChild(script); + + blocker.init(); + document.body.appendChild(container); + + // Give MutationObserver time to process + const activatedScript = document.querySelector('script[src="https://essential.example.com/nested.js"]'); + expect(activatedScript).toBeDefined(); + }); + }); +}); diff --git a/consent-sdk/src/core/ScriptBlocker.ts b/consent-sdk/src/core/ScriptBlocker.ts new file mode 100644 index 0000000..0cc587b --- /dev/null +++ b/consent-sdk/src/core/ScriptBlocker.ts @@ -0,0 +1,367 @@ +/** + * ScriptBlocker - Blockiert Skripte bis Consent erteilt wird + * + * Verwendet das data-consent Attribut zur Identifikation von + * Skripten, die erst nach Consent geladen werden duerfen. + * + * Beispiel: + * + */ + +import type { ConsentConfig, ConsentCategory } from '../types'; + +/** + * Script-Element mit Consent-Attributen + */ +interface ConsentScript extends HTMLScriptElement { + dataset: DOMStringMap & { + consent?: string; + src?: string; + }; +} + +/** + * iFrame-Element mit Consent-Attributen + */ +interface ConsentIframe extends HTMLIFrameElement { + dataset: DOMStringMap & { + consent?: string; + src?: string; + }; +} + +/** + * ScriptBlocker - Verwaltet Script-Blocking + */ +export class ScriptBlocker { + private config: ConsentConfig; + private observer: MutationObserver | null = null; + private enabledCategories: Set = new Set(['essential']); + private processedElements: WeakSet = new WeakSet(); + + constructor(config: ConsentConfig) { + this.config = config; + } + + /** + * Initialisieren und Observer starten + */ + init(): void { + if (typeof window === 'undefined') { + return; + } + + // Bestehende Elemente verarbeiten + this.processExistingElements(); + + // MutationObserver fuer neue Elemente + this.observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.processElement(node as Element); + } + } + } + }); + + this.observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + + this.log('ScriptBlocker initialized'); + } + + /** + * Kategorie aktivieren + */ + enableCategory(category: ConsentCategory): void { + if (this.enabledCategories.has(category)) { + return; + } + + this.enabledCategories.add(category); + this.log('Category enabled:', category); + + // Blockierte Elemente dieser Kategorie aktivieren + this.activateCategory(category); + } + + /** + * Kategorie deaktivieren + */ + disableCategory(category: ConsentCategory): void { + if (category === 'essential') { + // Essential kann nicht deaktiviert werden + return; + } + + this.enabledCategories.delete(category); + this.log('Category disabled:', category); + + // Hinweis: Bereits geladene Skripte koennen nicht entladen werden + // Page-Reload noetig fuer vollstaendige Deaktivierung + } + + /** + * Alle Kategorien blockieren (ausser Essential) + */ + blockAll(): void { + this.enabledCategories.clear(); + this.enabledCategories.add('essential'); + this.log('All categories blocked'); + } + + /** + * Pruefen ob Kategorie aktiviert + */ + isCategoryEnabled(category: ConsentCategory): boolean { + return this.enabledCategories.has(category); + } + + /** + * Observer stoppen + */ + destroy(): void { + this.observer?.disconnect(); + this.observer = null; + this.log('ScriptBlocker destroyed'); + } + + // =========================================================================== + // Internal Methods + // =========================================================================== + + /** + * Bestehende Elemente verarbeiten + */ + private processExistingElements(): void { + // Scripts mit data-consent + const scripts = document.querySelectorAll( + 'script[data-consent]' + ); + scripts.forEach((script) => this.processScript(script)); + + // iFrames mit data-consent + const iframes = document.querySelectorAll( + 'iframe[data-consent]' + ); + iframes.forEach((iframe) => this.processIframe(iframe)); + + this.log(`Processed ${scripts.length} scripts, ${iframes.length} iframes`); + } + + /** + * Element verarbeiten + */ + private processElement(element: Element): void { + if (element.tagName === 'SCRIPT') { + this.processScript(element as ConsentScript); + } else if (element.tagName === 'IFRAME') { + this.processIframe(element as ConsentIframe); + } + + // Auch Kinder verarbeiten + element + .querySelectorAll('script[data-consent]') + .forEach((script) => this.processScript(script)); + element + .querySelectorAll('iframe[data-consent]') + .forEach((iframe) => this.processIframe(iframe)); + } + + /** + * Script-Element verarbeiten + */ + private processScript(script: ConsentScript): void { + if (this.processedElements.has(script)) { + return; + } + + const category = script.dataset.consent as ConsentCategory | undefined; + if (!category) { + return; + } + + this.processedElements.add(script); + + if (this.enabledCategories.has(category)) { + this.activateScript(script); + } else { + this.log(`Script blocked (${category}):`, script.dataset.src || 'inline'); + } + } + + /** + * iFrame-Element verarbeiten + */ + private processIframe(iframe: ConsentIframe): void { + if (this.processedElements.has(iframe)) { + return; + } + + const category = iframe.dataset.consent as ConsentCategory | undefined; + if (!category) { + return; + } + + this.processedElements.add(iframe); + + if (this.enabledCategories.has(category)) { + this.activateIframe(iframe); + } else { + this.log(`iFrame blocked (${category}):`, iframe.dataset.src); + // Placeholder anzeigen + this.showPlaceholder(iframe, category); + } + } + + /** + * Script aktivieren + */ + private activateScript(script: ConsentScript): void { + const src = script.dataset.src; + + if (src) { + // Externes Script: neues Element erstellen + const newScript = document.createElement('script'); + + // Attribute kopieren + for (const attr of script.attributes) { + if (attr.name !== 'type' && attr.name !== 'data-src') { + newScript.setAttribute(attr.name, attr.value); + } + } + + newScript.src = src; + newScript.removeAttribute('data-consent'); + + // Altes Element ersetzen + script.parentNode?.replaceChild(newScript, script); + + this.log('External script activated:', src); + } else { + // Inline-Script: type aendern + const newScript = document.createElement('script'); + + for (const attr of script.attributes) { + if (attr.name !== 'type') { + newScript.setAttribute(attr.name, attr.value); + } + } + + newScript.textContent = script.textContent; + newScript.removeAttribute('data-consent'); + + script.parentNode?.replaceChild(newScript, script); + + this.log('Inline script activated'); + } + } + + /** + * iFrame aktivieren + */ + private activateIframe(iframe: ConsentIframe): void { + const src = iframe.dataset.src; + if (!src) { + return; + } + + // Placeholder entfernen falls vorhanden + const placeholder = iframe.parentElement?.querySelector( + '.bp-consent-placeholder' + ); + placeholder?.remove(); + + // src setzen + iframe.src = src; + iframe.removeAttribute('data-src'); + iframe.removeAttribute('data-consent'); + iframe.style.display = ''; + + this.log('iFrame activated:', src); + } + + /** + * Placeholder fuer blockierten iFrame anzeigen + */ + private showPlaceholder(iframe: ConsentIframe, category: ConsentCategory): void { + // iFrame verstecken + iframe.style.display = 'none'; + + // Placeholder erstellen + const placeholder = document.createElement('div'); + placeholder.className = 'bp-consent-placeholder'; + placeholder.setAttribute('data-category', category); + placeholder.innerHTML = ` + + `; + + // Click-Handler + const btn = placeholder.querySelector('button'); + btn?.addEventListener('click', () => { + // Event dispatchen damit ConsentManager reagieren kann + window.dispatchEvent( + new CustomEvent('bp-consent-request', { + detail: { category }, + }) + ); + }); + + // Nach iFrame einfuegen + iframe.parentNode?.insertBefore(placeholder, iframe.nextSibling); + } + + /** + * Alle Elemente einer Kategorie aktivieren + */ + private activateCategory(category: ConsentCategory): void { + // Scripts + const scripts = document.querySelectorAll( + `script[data-consent="${category}"]` + ); + scripts.forEach((script) => this.activateScript(script)); + + // iFrames + const iframes = document.querySelectorAll( + `iframe[data-consent="${category}"]` + ); + iframes.forEach((iframe) => this.activateIframe(iframe)); + + this.log( + `Activated ${scripts.length} scripts, ${iframes.length} iframes for ${category}` + ); + } + + /** + * Kategorie-Name fuer UI + */ + private getCategoryName(category: ConsentCategory): string { + const names: Record = { + essential: 'Essentielle Cookies', + functional: 'Funktionale Cookies', + analytics: 'Statistik-Cookies', + marketing: 'Marketing-Cookies', + social: 'Social Media-Cookies', + }; + return names[category] ?? category; + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ScriptBlocker]', ...args); + } + } +} + +export default ScriptBlocker; diff --git a/consent-sdk/src/core/index.ts b/consent-sdk/src/core/index.ts new file mode 100644 index 0000000..6d1fbb6 --- /dev/null +++ b/consent-sdk/src/core/index.ts @@ -0,0 +1,7 @@ +/** + * Core module exports + */ +export { ConsentManager } from './ConsentManager'; +export { ConsentStorage } from './ConsentStorage'; +export { ScriptBlocker } from './ScriptBlocker'; +export { ConsentAPI } from './ConsentAPI'; diff --git a/consent-sdk/src/index.ts b/consent-sdk/src/index.ts new file mode 100644 index 0000000..1a34b4e --- /dev/null +++ b/consent-sdk/src/index.ts @@ -0,0 +1,81 @@ +/** + * @breakpilot/consent-sdk + * + * DSGVO/TTDSG-konformes Consent Management SDK + * + * @example + * ```typescript + * import { ConsentManager } from '@breakpilot/consent-sdk'; + * + * const consent = new ConsentManager({ + * apiEndpoint: 'https://consent.example.com/api/v1', + * siteId: 'site_abc123', + * }); + * + * await consent.init(); + * + * if (consent.hasConsent('analytics')) { + * // Analytics laden + * } + * ``` + */ + +// Core +export { ConsentManager } from './core/ConsentManager'; +export { ConsentStorage } from './core/ConsentStorage'; +export { ScriptBlocker } from './core/ScriptBlocker'; +export { ConsentAPI } from './core/ConsentAPI'; + +// Utils +export { EventEmitter } from './utils/EventEmitter'; +export { generateFingerprint, generateFingerprintSync } from './utils/fingerprint'; + +// Types +export type { + // Categories + ConsentCategory, + ConsentCategories, + ConsentVendors, + + // State + ConsentState, + ConsentInput, + + // Config + ConsentConfig, + ConsentUIConfig, + ConsentBehaviorConfig, + TCFConfig, + PWAConfig, + BannerPosition, + BannerLayout, + BannerTheme, + + // Vendors + ConsentVendor, + CookieInfo, + + // API + ConsentAPIResponse, + SiteConfigResponse, + CategoryConfig, + LegalConfig, + + // Events + ConsentEventType, + ConsentEventCallback, + ConsentEventData, + + // Storage + ConsentStorageAdapter, + + // Translations + ConsentTranslations, + SupportedLanguage, +} from './types'; + +// Version +export { SDK_VERSION } from './version'; + +// Default export +export { ConsentManager as default } from './core/ConsentManager'; diff --git a/consent-sdk/src/mobile/README.md b/consent-sdk/src/mobile/README.md new file mode 100644 index 0000000..cd0078e --- /dev/null +++ b/consent-sdk/src/mobile/README.md @@ -0,0 +1,182 @@ +# Mobile SDKs - @breakpilot/consent-sdk + +## Übersicht + +Die Mobile SDKs bieten native Integration für iOS, Android und Flutter. + +## SDK Übersicht + +| Platform | Sprache | Min Version | Status | +|----------|---------|-------------|--------| +| iOS | Swift 5.9+ | iOS 15.0+ | 📋 Spec | +| Android | Kotlin | API 26+ | 📋 Spec | +| Flutter | Dart 3.0+ | Flutter 3.16+ | 📋 Spec | + +## Architektur + +``` +Mobile SDK +├── Core (shared) +│ ├── ConsentManager +│ ├── ConsentStorage (Keychain/SharedPrefs) +│ ├── API Client +│ └── Device Fingerprint +├── UI Components +│ ├── ConsentBanner +│ ├── ConsentSettings +│ └── ConsentGate +└── Platform-specific + ├── iOS: SwiftUI + UIKit + ├── Android: Jetpack Compose + XML + └── Flutter: Widgets +``` + +## Feature-Parität mit Web SDK + +| Feature | iOS | Android | Flutter | +|---------|-----|---------|---------| +| Consent Storage | Keychain | SharedPrefs | SecureStorage | +| Banner UI | SwiftUI | Compose | Widget | +| Settings Modal | ✓ | ✓ | ✓ | +| Category Control | ✓ | ✓ | ✓ | +| Vendor Control | ✓ | ✓ | ✓ | +| Offline Support | ✓ | ✓ | ✓ | +| Google Consent Mode | ✓ | ✓ | ✓ | +| ATT Integration | ✓ | - | ✓ (iOS) | +| TCF 2.2 | ✓ | ✓ | ✓ | + +## Installation + +### iOS (Swift Package Manager) + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/breakpilot/consent-sdk-ios.git", from: "1.0.0") +] +``` + +### Android (Gradle) + +```kotlin +// build.gradle.kts +dependencies { + implementation("com.breakpilot:consent-sdk:1.0.0") +} +``` + +### Flutter + +```yaml +# pubspec.yaml +dependencies: + breakpilot_consent_sdk: ^1.0.0 +``` + +## Quick Start + +### iOS + +```swift +import BreakpilotConsentSDK + +// AppDelegate.swift +ConsentManager.shared.configure( + apiEndpoint: "https://consent.example.com/api/v1", + siteId: "site_abc123" +) + +// ContentView.swift (SwiftUI) +struct ContentView: View { + @EnvironmentObject var consent: ConsentManager + + var body: some View { + VStack { + if consent.hasConsent(.analytics) { + AnalyticsView() + } + } + .consentBanner() + } +} +``` + +### Android + +```kotlin +import com.breakpilot.consent.ConsentManager +import com.breakpilot.consent.ui.ConsentBanner + +// Application.kt +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + ConsentManager.configure( + context = this, + apiEndpoint = "https://consent.example.com/api/v1", + siteId = "site_abc123" + ) + } +} + +// MainActivity.kt (Jetpack Compose) +@Composable +fun MainScreen() { + val consent = ConsentManager.current + + if (consent.hasConsent(ConsentCategory.ANALYTICS)) { + AnalyticsComponent() + } + + ConsentBanner() +} +``` + +### Flutter + +```dart +import 'package:breakpilot_consent_sdk/consent_sdk.dart'; + +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await ConsentManager.configure( + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + ); + + runApp(MyApp()); +} + +// Widget +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ConsentProvider( + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + ConsentGate( + category: ConsentCategory.analytics, + child: AnalyticsWidget(), + placeholder: Text('Analytics nicht aktiviert'), + ), + ], + ), + bottomSheet: ConsentBanner(), + ), + ), + ); + } +} +``` + +## Dateien + +Siehe die einzelnen Platform-SDKs: + +- [iOS SDK Spec](./ios/README.md) +- [Android SDK Spec](./android/README.md) +- [Flutter SDK Spec](./flutter/README.md) diff --git a/consent-sdk/src/mobile/android/ConsentManager.kt b/consent-sdk/src/mobile/android/ConsentManager.kt new file mode 100644 index 0000000..f46cf12 --- /dev/null +++ b/consent-sdk/src/mobile/android/ConsentManager.kt @@ -0,0 +1,499 @@ +/** + * Android Consent SDK - ConsentManager + * + * DSGVO/TTDSG-konformes Consent Management fuer Android Apps. + * + * Nutzung: + * 1. In Application.onCreate() konfigurieren + * 2. In Activities/Fragments mit ConsentManager.current nutzen + * 3. In Jetpack Compose mit rememberConsentState() + * + * Copyright (c) 2025 BreakPilot + * Apache License 2.0 + */ + +package com.breakpilot.consent + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.provider.Settings +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.security.MessageDigest +import java.util.* + +// ============================================================================= +// Consent Categories +// ============================================================================= + +/** + * Standard-Consent-Kategorien nach IAB TCF 2.2 + */ +enum class ConsentCategory { + ESSENTIAL, // Technisch notwendig + FUNCTIONAL, // Personalisierung + ANALYTICS, // Nutzungsanalyse + MARKETING, // Werbung + SOCIAL // Social Media +} + +// ============================================================================= +// Consent State +// ============================================================================= + +/** + * Aktueller Consent-Zustand + */ +@Serializable +data class ConsentState( + val categories: Map = defaultCategories(), + val vendors: Map = emptyMap(), + val timestamp: Long = System.currentTimeMillis(), + val version: String = "1.0.0", + val consentId: String? = null, + val expiresAt: Long? = null, + val tcfString: String? = null +) { + companion object { + fun defaultCategories() = mapOf( + ConsentCategory.ESSENTIAL to true, + ConsentCategory.FUNCTIONAL to false, + ConsentCategory.ANALYTICS to false, + ConsentCategory.MARKETING to false, + ConsentCategory.SOCIAL to false + ) + + val DEFAULT = ConsentState() + } +} + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * SDK-Konfiguration + */ +data class ConsentConfig( + val apiEndpoint: String, + val siteId: String, + val language: String = Locale.getDefault().language, + val showRejectAll: Boolean = true, + val showAcceptAll: Boolean = true, + val granularControl: Boolean = true, + val rememberDays: Int = 365, + val debug: Boolean = false +) + +// ============================================================================= +// Consent Manager +// ============================================================================= + +/** + * Haupt-Manager fuer Consent-Verwaltung + */ +class ConsentManager private constructor() { + + // State + private val _consent = MutableStateFlow(ConsentState.DEFAULT) + val consent: StateFlow = _consent.asStateFlow() + + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _isBannerVisible = MutableStateFlow(false) + val isBannerVisible: StateFlow = _isBannerVisible.asStateFlow() + + private val _isSettingsVisible = MutableStateFlow(false) + val isSettingsVisible: StateFlow = _isSettingsVisible.asStateFlow() + + // Private + private var config: ConsentConfig? = null + private var storage: ConsentStorage? = null + private var apiClient: ConsentApiClient? = null + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + // Singleton + companion object { + @Volatile + private var INSTANCE: ConsentManager? = null + + val current: ConsentManager + get() = INSTANCE ?: synchronized(this) { + INSTANCE ?: ConsentManager().also { INSTANCE = it } + } + + /** + * Konfiguriert den ConsentManager + * Sollte in Application.onCreate() aufgerufen werden + */ + fun configure(context: Context, config: ConsentConfig) { + current.apply { + this.config = config + this.storage = ConsentStorage(context) + this.apiClient = ConsentApiClient(config.apiEndpoint, config.siteId) + + if (config.debug) { + println("[ConsentSDK] Configured with siteId: ${config.siteId}") + } + + scope.launch { + initialize(context) + } + } + } + } + + // ========================================================================== + // Initialization + // ========================================================================== + + private suspend fun initialize(context: Context) { + try { + // Lokalen Consent laden + storage?.load()?.let { stored -> + // Pruefen ob abgelaufen + val expiresAt = stored.expiresAt + if (expiresAt != null && System.currentTimeMillis() > expiresAt) { + _consent.value = ConsentState.DEFAULT + storage?.clear() + } else { + _consent.value = stored + } + } + + // Vom Server synchronisieren + try { + apiClient?.getConsent(DeviceFingerprint.generate(context))?.let { serverConsent -> + _consent.value = serverConsent + storage?.save(serverConsent) + } + } catch (e: Exception) { + if (config?.debug == true) { + println("[ConsentSDK] Failed to sync consent: $e") + } + } + + _isInitialized.value = true + + // Banner anzeigen falls noetig + if (needsConsent) { + showBanner() + } + } finally { + _isLoading.value = false + } + } + + // ========================================================================== + // Public API + // ========================================================================== + + /** + * Prueft ob Consent fuer Kategorie erteilt wurde + */ + fun hasConsent(category: ConsentCategory): Boolean { + // Essential ist immer erlaubt + if (category == ConsentCategory.ESSENTIAL) return true + return consent.value.categories[category] ?: false + } + + /** + * Prueft ob Consent eingeholt werden muss + */ + val needsConsent: Boolean + get() = consent.value.consentId == null + + /** + * Alle Kategorien akzeptieren + */ + suspend fun acceptAll() { + val newCategories = ConsentCategory.values().associateWith { true } + val newConsent = consent.value.copy( + categories = newCategories, + timestamp = System.currentTimeMillis() + ) + saveConsent(newConsent) + hideBanner() + } + + /** + * Alle nicht-essentiellen Kategorien ablehnen + */ + suspend fun rejectAll() { + val newCategories = ConsentCategory.values().associateWith { + it == ConsentCategory.ESSENTIAL + } + val newConsent = consent.value.copy( + categories = newCategories, + timestamp = System.currentTimeMillis() + ) + saveConsent(newConsent) + hideBanner() + } + + /** + * Auswahl speichern + */ + suspend fun saveSelection(categories: Map) { + val updated = categories.toMutableMap() + updated[ConsentCategory.ESSENTIAL] = true // Essential immer true + val newConsent = consent.value.copy( + categories = updated, + timestamp = System.currentTimeMillis() + ) + saveConsent(newConsent) + hideBanner() + } + + // ========================================================================== + // UI Control + // ========================================================================== + + fun showBanner() { + _isBannerVisible.value = true + } + + fun hideBanner() { + _isBannerVisible.value = false + } + + fun showSettings() { + _isSettingsVisible.value = true + } + + fun hideSettings() { + _isSettingsVisible.value = false + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + private suspend fun saveConsent(newConsent: ConsentState) { + // Lokal speichern + storage?.save(newConsent) + + // An Server senden + try { + val response = apiClient?.saveConsent( + newConsent, + DeviceFingerprint.generate(storage?.context!!) + ) + val updated = newConsent.copy( + consentId = response?.consentId, + expiresAt = response?.expiresAt + ) + _consent.value = updated + storage?.save(updated) + } catch (e: Exception) { + // Lokal speichern auch bei Fehler + _consent.value = newConsent + if (config?.debug == true) { + println("[ConsentSDK] Failed to sync consent: $e") + } + } + } +} + +// ============================================================================= +// Storage +// ============================================================================= + +/** + * Sichere Speicherung mit EncryptedSharedPreferences + */ +internal class ConsentStorage(val context: Context) { + private val prefs: SharedPreferences by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences.create( + context, + "breakpilot_consent", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private val json = Json { ignoreUnknownKeys = true } + + fun load(): ConsentState? { + val data = prefs.getString("consent_state", null) ?: return null + return try { + json.decodeFromString(data) + } catch (e: Exception) { + null + } + } + + fun save(consent: ConsentState) { + val data = json.encodeToString(consent) + prefs.edit().putString("consent_state", data).apply() + } + + fun clear() { + prefs.edit().remove("consent_state").apply() + } +} + +// ============================================================================= +// API Client +// ============================================================================= + +/** + * API Client fuer Backend-Kommunikation + */ +internal class ConsentApiClient( + private val baseUrl: String, + private val siteId: String +) { + private val client = OkHttpClient() + private val json = Json { ignoreUnknownKeys = true } + + @Serializable + data class ConsentResponse( + val consentId: String, + val expiresAt: Long + ) + + suspend fun getConsent(fingerprint: String): ConsentState? = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint") + .get() + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext null + val body = response.body?.string() ?: return@withContext null + json.decodeFromString(body) + } + } + + suspend fun saveConsent( + consent: ConsentState, + fingerprint: String + ): ConsentResponse = withContext(Dispatchers.IO) { + val body = """ + { + "site_id": "$siteId", + "device_fingerprint": "$fingerprint", + "categories": ${json.encodeToString(consent.categories.mapKeys { it.key.name.lowercase() })}, + "vendors": ${json.encodeToString(consent.vendors)}, + "platform": "android", + "app_version": "${BuildConfig.VERSION_NAME}" + } + """.trimIndent() + + val request = Request.Builder() + .url("$baseUrl/banner/consent") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: throw Exception("Empty response") + json.decodeFromString(responseBody) + } + } +} + +// ============================================================================= +// Device Fingerprint +// ============================================================================= + +/** + * Privacy-konformer Device Fingerprint + */ +internal object DeviceFingerprint { + fun generate(context: Context): String { + // Android ID (reset bei Factory Reset) + val androidId = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID + ) ?: UUID.randomUUID().toString() + + // Device Info + val model = Build.MODEL + val version = Build.VERSION.SDK_INT.toString() + val locale = Locale.getDefault().toString() + + // Hash erstellen + val raw = "$androidId-$model-$version-$locale" + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(raw.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } +} + +// ============================================================================= +// Jetpack Compose Integration +// ============================================================================= + +/** + * State Holder fuer Compose + */ +@Composable +fun rememberConsentState(): State { + return ConsentManager.current.consent.collectAsState() +} + +/** + * Banner Visibility State + */ +@Composable +fun rememberBannerVisibility(): State { + return ConsentManager.current.isBannerVisible.collectAsState() +} + +/** + * Consent Gate - Zeigt Inhalt nur bei Consent + */ +@Composable +fun ConsentGate( + category: ConsentCategory, + placeholder: @Composable () -> Unit = {}, + content: @Composable () -> Unit +) { + val consent by rememberConsentState() + + if (ConsentManager.current.hasConsent(category)) { + content() + } else { + placeholder() + } +} + +/** + * Local Composition fuer ConsentManager + */ +val LocalConsentManager = staticCompositionLocalOf { ConsentManager.current } + +/** + * Consent Provider + */ +@Composable +fun ConsentProvider( + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalConsentManager provides ConsentManager.current + ) { + content() + } +} diff --git a/consent-sdk/src/mobile/flutter/consent_sdk.dart b/consent-sdk/src/mobile/flutter/consent_sdk.dart new file mode 100644 index 0000000..b032d0e --- /dev/null +++ b/consent-sdk/src/mobile/flutter/consent_sdk.dart @@ -0,0 +1,658 @@ +/// Flutter Consent SDK +/// +/// DSGVO/TTDSG-konformes Consent Management fuer Flutter Apps. +/// +/// Nutzung: +/// 1. In main() mit ConsentManager.configure() initialisieren +/// 2. App mit ConsentProvider wrappen +/// 3. Mit ConsentGate Inhalte schuetzen +/// +/// Copyright (c) 2025 BreakPilot +/// Apache License 2.0 + +library consent_sdk; + +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; + +// ============================================================================= +// Consent Categories +// ============================================================================= + +/// Standard-Consent-Kategorien nach IAB TCF 2.2 +enum ConsentCategory { + essential, // Technisch notwendig + functional, // Personalisierung + analytics, // Nutzungsanalyse + marketing, // Werbung + social, // Social Media +} + +// ============================================================================= +// Consent State +// ============================================================================= + +/// Aktueller Consent-Zustand +class ConsentState { + final Map categories; + final Map vendors; + final DateTime timestamp; + final String version; + final String? consentId; + final DateTime? expiresAt; + final String? tcfString; + + const ConsentState({ + required this.categories, + this.vendors = const {}, + required this.timestamp, + this.version = '1.0.0', + this.consentId, + this.expiresAt, + this.tcfString, + }); + + /// Default State mit nur essential = true + factory ConsentState.defaultState() { + return ConsentState( + categories: { + ConsentCategory.essential: true, + ConsentCategory.functional: false, + ConsentCategory.analytics: false, + ConsentCategory.marketing: false, + ConsentCategory.social: false, + }, + timestamp: DateTime.now(), + ); + } + + ConsentState copyWith({ + Map? categories, + Map? vendors, + DateTime? timestamp, + String? version, + String? consentId, + DateTime? expiresAt, + String? tcfString, + }) { + return ConsentState( + categories: categories ?? this.categories, + vendors: vendors ?? this.vendors, + timestamp: timestamp ?? this.timestamp, + version: version ?? this.version, + consentId: consentId ?? this.consentId, + expiresAt: expiresAt ?? this.expiresAt, + tcfString: tcfString ?? this.tcfString, + ); + } + + Map toJson() => { + 'categories': categories.map((k, v) => MapEntry(k.name, v)), + 'vendors': vendors, + 'timestamp': timestamp.toIso8601String(), + 'version': version, + 'consentId': consentId, + 'expiresAt': expiresAt?.toIso8601String(), + 'tcfString': tcfString, + }; + + factory ConsentState.fromJson(Map json) { + return ConsentState( + categories: (json['categories'] as Map).map( + (k, v) => MapEntry( + ConsentCategory.values.firstWhere((e) => e.name == k), + v as bool, + ), + ), + vendors: Map.from(json['vendors'] ?? {}), + timestamp: DateTime.parse(json['timestamp']), + version: json['version'] ?? '1.0.0', + consentId: json['consentId'], + expiresAt: json['expiresAt'] != null + ? DateTime.parse(json['expiresAt']) + : null, + tcfString: json['tcfString'], + ); + } +} + +// ============================================================================= +// Configuration +// ============================================================================= + +/// SDK-Konfiguration +class ConsentConfig { + final String apiEndpoint; + final String siteId; + final String language; + final bool showRejectAll; + final bool showAcceptAll; + final bool granularControl; + final int rememberDays; + final bool debug; + + const ConsentConfig({ + required this.apiEndpoint, + required this.siteId, + this.language = 'en', + this.showRejectAll = true, + this.showAcceptAll = true, + this.granularControl = true, + this.rememberDays = 365, + this.debug = false, + }); +} + +// ============================================================================= +// Consent Manager +// ============================================================================= + +/// Haupt-Manager fuer Consent-Verwaltung +class ConsentManager extends ChangeNotifier { + // Singleton + static ConsentManager? _instance; + static ConsentManager get instance => _instance!; + + // State + ConsentState _consent = ConsentState.defaultState(); + bool _isInitialized = false; + bool _isLoading = true; + bool _isBannerVisible = false; + bool _isSettingsVisible = false; + + // Private + ConsentConfig? _config; + late ConsentStorage _storage; + late ConsentApiClient _apiClient; + + // Getters + ConsentState get consent => _consent; + bool get isInitialized => _isInitialized; + bool get isLoading => _isLoading; + bool get isBannerVisible => _isBannerVisible; + bool get isSettingsVisible => _isSettingsVisible; + bool get needsConsent => _consent.consentId == null; + + // Private constructor + ConsentManager._(); + + /// Konfiguriert den ConsentManager + /// Sollte in main() vor runApp() aufgerufen werden + static Future configure(ConsentConfig config) async { + _instance = ConsentManager._(); + _instance!._config = config; + _instance!._storage = ConsentStorage(); + _instance!._apiClient = ConsentApiClient( + baseUrl: config.apiEndpoint, + siteId: config.siteId, + ); + + if (config.debug) { + debugPrint('[ConsentSDK] Configured with siteId: ${config.siteId}'); + } + + await _instance!._initialize(); + } + + // ========================================================================== + // Initialization + // ========================================================================== + + Future _initialize() async { + try { + // Lokalen Consent laden + final stored = await _storage.load(); + if (stored != null) { + // Pruefen ob abgelaufen + if (stored.expiresAt != null && + DateTime.now().isAfter(stored.expiresAt!)) { + _consent = ConsentState.defaultState(); + await _storage.clear(); + } else { + _consent = stored; + } + } + + // Vom Server synchronisieren + try { + final fingerprint = await DeviceFingerprint.generate(); + final serverConsent = await _apiClient.getConsent(fingerprint); + if (serverConsent != null) { + _consent = serverConsent; + await _storage.save(_consent); + } + } catch (e) { + if (_config?.debug == true) { + debugPrint('[ConsentSDK] Failed to sync consent: $e'); + } + } + + _isInitialized = true; + + // Banner anzeigen falls noetig + if (needsConsent) { + showBanner(); + } + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // ========================================================================== + // Public API + // ========================================================================== + + /// Prueft ob Consent fuer Kategorie erteilt wurde + bool hasConsent(ConsentCategory category) { + // Essential ist immer erlaubt + if (category == ConsentCategory.essential) return true; + return _consent.categories[category] ?? false; + } + + /// Alle Kategorien akzeptieren + Future acceptAll() async { + final newCategories = { + for (var cat in ConsentCategory.values) cat: true + }; + final newConsent = _consent.copyWith( + categories: newCategories, + timestamp: DateTime.now(), + ); + await _saveConsent(newConsent); + hideBanner(); + } + + /// Alle nicht-essentiellen Kategorien ablehnen + Future rejectAll() async { + final newCategories = { + for (var cat in ConsentCategory.values) + cat: cat == ConsentCategory.essential + }; + final newConsent = _consent.copyWith( + categories: newCategories, + timestamp: DateTime.now(), + ); + await _saveConsent(newConsent); + hideBanner(); + } + + /// Auswahl speichern + Future saveSelection(Map categories) async { + final updated = Map.from(categories); + updated[ConsentCategory.essential] = true; // Essential immer true + final newConsent = _consent.copyWith( + categories: updated, + timestamp: DateTime.now(), + ); + await _saveConsent(newConsent); + hideBanner(); + } + + // ========================================================================== + // UI Control + // ========================================================================== + + void showBanner() { + _isBannerVisible = true; + notifyListeners(); + } + + void hideBanner() { + _isBannerVisible = false; + notifyListeners(); + } + + void showSettings() { + _isSettingsVisible = true; + notifyListeners(); + } + + void hideSettings() { + _isSettingsVisible = false; + notifyListeners(); + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + Future _saveConsent(ConsentState newConsent) async { + // Lokal speichern + await _storage.save(newConsent); + + // An Server senden + try { + final fingerprint = await DeviceFingerprint.generate(); + final response = await _apiClient.saveConsent(newConsent, fingerprint); + final updated = newConsent.copyWith( + consentId: response['consentId'], + expiresAt: DateTime.parse(response['expiresAt']), + ); + _consent = updated; + await _storage.save(updated); + } catch (e) { + // Lokal speichern auch bei Fehler + _consent = newConsent; + if (_config?.debug == true) { + debugPrint('[ConsentSDK] Failed to sync consent: $e'); + } + } + + notifyListeners(); + } +} + +// ============================================================================= +// Storage +// ============================================================================= + +/// Sichere Speicherung mit flutter_secure_storage +class ConsentStorage { + final _storage = const FlutterSecureStorage(); + static const _key = 'breakpilot_consent_state'; + + Future load() async { + final data = await _storage.read(key: _key); + if (data == null) return null; + try { + return ConsentState.fromJson(jsonDecode(data)); + } catch (e) { + return null; + } + } + + Future save(ConsentState consent) async { + final data = jsonEncode(consent.toJson()); + await _storage.write(key: _key, value: data); + } + + Future clear() async { + await _storage.delete(key: _key); + } +} + +// ============================================================================= +// API Client +// ============================================================================= + +/// API Client fuer Backend-Kommunikation +class ConsentApiClient { + final String baseUrl; + final String siteId; + + ConsentApiClient({ + required this.baseUrl, + required this.siteId, + }); + + Future getConsent(String fingerprint) async { + final response = await http.get( + Uri.parse('$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint'), + ); + + if (response.statusCode != 200) return null; + return ConsentState.fromJson(jsonDecode(response.body)); + } + + Future> saveConsent( + ConsentState consent, + String fingerprint, + ) async { + final response = await http.post( + Uri.parse('$baseUrl/banner/consent'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'site_id': siteId, + 'device_fingerprint': fingerprint, + 'categories': consent.categories.map((k, v) => MapEntry(k.name, v)), + 'vendors': consent.vendors, + 'platform': Platform.isIOS ? 'ios' : 'android', + 'app_version': '1.0.0', // TODO: Get from package_info_plus + }), + ); + + return jsonDecode(response.body); + } +} + +// ============================================================================= +// Device Fingerprint +// ============================================================================= + +/// Privacy-konformer Device Fingerprint +class DeviceFingerprint { + static Future generate() async { + final deviceInfo = DeviceInfoPlugin(); + String rawId; + + if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + rawId = '${iosInfo.identifierForVendor}-${iosInfo.model}-${iosInfo.systemVersion}'; + } else if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + rawId = '${androidInfo.id}-${androidInfo.model}-${androidInfo.version.sdkInt}'; + } else { + rawId = DateTime.now().millisecondsSinceEpoch.toString(); + } + + // SHA-256 Hash + final bytes = utf8.encode(rawId); + final digest = sha256.convert(bytes); + return digest.toString(); + } +} + +// ============================================================================= +// Flutter Widgets +// ============================================================================= + +/// Consent Provider - Wraps the app +class ConsentProvider extends StatelessWidget { + final Widget child; + + const ConsentProvider({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: ConsentManager.instance, + child: child, + ); + } +} + +/// Consent Gate - Zeigt Inhalt nur bei Consent +class ConsentGate extends StatelessWidget { + final ConsentCategory category; + final Widget child; + final Widget? placeholder; + final Widget? loading; + + const ConsentGate({ + super.key, + required this.category, + required this.child, + this.placeholder, + this.loading, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, consent, _) { + if (consent.isLoading) { + return loading ?? const SizedBox.shrink(); + } + + if (!consent.hasConsent(category)) { + return placeholder ?? const SizedBox.shrink(); + } + + return child; + }, + ); + } +} + +/// Consent Banner - Default Banner UI +class ConsentBanner extends StatelessWidget { + final String? title; + final String? description; + final String? acceptAllText; + final String? rejectAllText; + final String? settingsText; + + const ConsentBanner({ + super.key, + this.title, + this.description, + this.acceptAllText, + this.rejectAllText, + this.settingsText, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, consent, _) { + if (!consent.isBannerVisible) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, -5), + ), + ], + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title ?? 'Datenschutzeinstellungen', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text( + description ?? + 'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: consent.rejectAll, + child: Text(rejectAllText ?? 'Alle ablehnen'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: consent.showSettings, + child: Text(settingsText ?? 'Einstellungen'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: consent.acceptAll, + child: Text(acceptAllText ?? 'Alle akzeptieren'), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} + +/// Consent Placeholder - Placeholder fuer blockierten Inhalt +class ConsentPlaceholder extends StatelessWidget { + final ConsentCategory category; + final String? message; + final String? buttonText; + + const ConsentPlaceholder({ + super.key, + required this.category, + this.message, + this.buttonText, + }); + + String get _categoryName { + switch (category) { + case ConsentCategory.essential: + return 'Essentielle Cookies'; + case ConsentCategory.functional: + return 'Funktionale Cookies'; + case ConsentCategory.analytics: + return 'Statistik-Cookies'; + case ConsentCategory.marketing: + return 'Marketing-Cookies'; + case ConsentCategory.social: + return 'Social Media-Cookies'; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message ?? 'Dieser Inhalt erfordert $_categoryName.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: ConsentManager.instance.showSettings, + child: Text(buttonText ?? 'Cookie-Einstellungen öffnen'), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Extension for easy context access +// ============================================================================= + +extension ConsentExtension on BuildContext { + ConsentManager get consent => Provider.of(this, listen: false); + + bool hasConsent(ConsentCategory category) => consent.hasConsent(category); +} diff --git a/consent-sdk/src/mobile/ios/ConsentManager.swift b/consent-sdk/src/mobile/ios/ConsentManager.swift new file mode 100644 index 0000000..bf4133f --- /dev/null +++ b/consent-sdk/src/mobile/ios/ConsentManager.swift @@ -0,0 +1,517 @@ +/** + * iOS Consent SDK - ConsentManager + * + * DSGVO/TTDSG-konformes Consent Management fuer iOS Apps. + * + * Nutzung: + * 1. Im AppDelegate/App.init() konfigurieren + * 2. In SwiftUI Views mit @EnvironmentObject nutzen + * 3. Banner mit .consentBanner() Modifier anzeigen + * + * Copyright (c) 2025 BreakPilot + * Apache License 2.0 + */ + +import Foundation +import SwiftUI +import Combine +import CryptoKit + +// MARK: - Consent Categories + +/// Standard-Consent-Kategorien nach IAB TCF 2.2 +public enum ConsentCategory: String, CaseIterable, Codable { + case essential // Technisch notwendig + case functional // Personalisierung + case analytics // Nutzungsanalyse + case marketing // Werbung + case social // Social Media +} + +// MARK: - Consent State + +/// Aktueller Consent-Zustand +public struct ConsentState: Codable, Equatable { + public var categories: [ConsentCategory: Bool] + public var vendors: [String: Bool] + public var timestamp: Date + public var version: String + public var consentId: String? + public var expiresAt: Date? + public var tcfString: String? + + public init( + categories: [ConsentCategory: Bool] = [:], + vendors: [String: Bool] = [:], + timestamp: Date = Date(), + version: String = "1.0.0" + ) { + self.categories = categories + self.vendors = vendors + self.timestamp = timestamp + self.version = version + } + + /// Default State mit nur essential = true + public static var `default`: ConsentState { + ConsentState( + categories: [ + .essential: true, + .functional: false, + .analytics: false, + .marketing: false, + .social: false + ] + ) + } +} + +// MARK: - Configuration + +/// SDK-Konfiguration +public struct ConsentConfig { + public let apiEndpoint: String + public let siteId: String + public var language: String = Locale.current.language.languageCode?.identifier ?? "en" + public var showRejectAll: Bool = true + public var showAcceptAll: Bool = true + public var granularControl: Bool = true + public var rememberDays: Int = 365 + public var debug: Bool = false + + public init(apiEndpoint: String, siteId: String) { + self.apiEndpoint = apiEndpoint + self.siteId = siteId + } +} + +// MARK: - Consent Manager + +/// Haupt-Manager fuer Consent-Verwaltung +@MainActor +public final class ConsentManager: ObservableObject { + + // MARK: Singleton + + public static let shared = ConsentManager() + + // MARK: Published Properties + + @Published public private(set) var consent: ConsentState = .default + @Published public private(set) var isInitialized: Bool = false + @Published public private(set) var isLoading: Bool = true + @Published public private(set) var isBannerVisible: Bool = false + @Published public private(set) var isSettingsVisible: Bool = false + + // MARK: Private Properties + + private var config: ConsentConfig? + private var storage: ConsentStorage? + private var apiClient: ConsentAPIClient? + private var cancellables = Set() + + // MARK: - Initialization + + private init() {} + + /// Konfiguriert den ConsentManager + public func configure(_ config: ConsentConfig) { + self.config = config + self.storage = ConsentStorage() + self.apiClient = ConsentAPIClient( + baseURL: config.apiEndpoint, + siteId: config.siteId + ) + + if config.debug { + print("[ConsentSDK] Configured with siteId: \(config.siteId)") + } + + Task { + await initialize() + } + } + + /// Initialisiert und laedt gespeicherten Consent + private func initialize() async { + defer { isLoading = false } + + // Lokalen Consent laden + if let stored = storage?.load() { + consent = stored + + // Pruefen ob abgelaufen + if let expiresAt = stored.expiresAt, Date() > expiresAt { + consent = .default + storage?.clear() + } + } + + // Vom Server synchronisieren (optional) + do { + if let serverConsent = try await apiClient?.getConsent( + fingerprint: DeviceFingerprint.generate() + ) { + consent = serverConsent + storage?.save(consent) + } + } catch { + if config?.debug == true { + print("[ConsentSDK] Failed to sync consent: \(error)") + } + } + + isInitialized = true + + // Banner anzeigen falls noetig + if needsConsent { + showBanner() + } + } + + // MARK: - Public API + + /// Prueft ob Consent fuer Kategorie erteilt wurde + public func hasConsent(_ category: ConsentCategory) -> Bool { + // Essential ist immer erlaubt + if category == .essential { return true } + return consent.categories[category] ?? false + } + + /// Prueft ob Consent eingeholt werden muss + public var needsConsent: Bool { + consent.consentId == nil + } + + /// Alle Kategorien akzeptieren + public func acceptAll() async { + var newConsent = consent + for category in ConsentCategory.allCases { + newConsent.categories[category] = true + } + newConsent.timestamp = Date() + + await saveConsent(newConsent) + hideBanner() + } + + /// Alle nicht-essentiellen Kategorien ablehnen + public func rejectAll() async { + var newConsent = consent + for category in ConsentCategory.allCases { + newConsent.categories[category] = category == .essential + } + newConsent.timestamp = Date() + + await saveConsent(newConsent) + hideBanner() + } + + /// Auswahl speichern + public func saveSelection(_ categories: [ConsentCategory: Bool]) async { + var newConsent = consent + newConsent.categories = categories + newConsent.categories[.essential] = true // Essential immer true + newConsent.timestamp = Date() + + await saveConsent(newConsent) + hideBanner() + } + + // MARK: - UI Control + + /// Banner anzeigen + public func showBanner() { + isBannerVisible = true + } + + /// Banner ausblenden + public func hideBanner() { + isBannerVisible = false + } + + /// Einstellungen anzeigen + public func showSettings() { + isSettingsVisible = true + } + + /// Einstellungen ausblenden + public func hideSettings() { + isSettingsVisible = false + } + + // MARK: - Private Methods + + private func saveConsent(_ newConsent: ConsentState) async { + // Lokal speichern + storage?.save(newConsent) + + // An Server senden + do { + let response = try await apiClient?.saveConsent( + consent: newConsent, + fingerprint: DeviceFingerprint.generate() + ) + var updated = newConsent + updated.consentId = response?.consentId + updated.expiresAt = response?.expiresAt + consent = updated + storage?.save(updated) + } catch { + // Lokal speichern auch bei Fehler + consent = newConsent + if config?.debug == true { + print("[ConsentSDK] Failed to sync consent: \(error)") + } + } + } +} + +// MARK: - Storage + +/// Sichere Speicherung im Keychain +final class ConsentStorage { + private let key = "com.breakpilot.consent.state" + + func load() -> ConsentState? { + guard let data = KeychainHelper.read(key: key) else { return nil } + return try? JSONDecoder().decode(ConsentState.self, from: data) + } + + func save(_ consent: ConsentState) { + guard let data = try? JSONEncoder().encode(consent) else { return } + KeychainHelper.write(data: data, key: key) + } + + func clear() { + KeychainHelper.delete(key: key) + } +} + +/// Keychain Helper +enum KeychainHelper { + static func write(data: Data, key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + static func read(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true + ] + var result: AnyObject? + SecItemCopyMatching(query as CFDictionary, &result) + return result as? Data + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - API Client + +/// API Client fuer Backend-Kommunikation +final class ConsentAPIClient { + private let baseURL: String + private let siteId: String + + init(baseURL: String, siteId: String) { + self.baseURL = baseURL + self.siteId = siteId + } + + struct ConsentResponse: Codable { + let consentId: String + let expiresAt: Date + } + + func getConsent(fingerprint: String) async throws -> ConsentState? { + let url = URL(string: "\(baseURL)/banner/consent?site_id=\(siteId)&fingerprint=\(fingerprint)")! + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + + return try JSONDecoder().decode(ConsentState.self, from: data) + } + + func saveConsent(consent: ConsentState, fingerprint: String) async throws -> ConsentResponse { + var request = URLRequest(url: URL(string: "\(baseURL)/banner/consent")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "site_id": siteId, + "device_fingerprint": fingerprint, + "categories": Dictionary( + uniqueKeysWithValues: consent.categories.map { ($0.key.rawValue, $0.value) } + ), + "vendors": consent.vendors, + "platform": "ios", + "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder().decode(ConsentResponse.self, from: data) + } +} + +// MARK: - Device Fingerprint + +/// Privacy-konformer Device Fingerprint +enum DeviceFingerprint { + static func generate() -> String { + // Vendor ID (reset-safe) + let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + + // System Info + let model = UIDevice.current.model + let systemVersion = UIDevice.current.systemVersion + let locale = Locale.current.identifier + + // Hash erstellen + let raw = "\(vendorId)-\(model)-\(systemVersion)-\(locale)" + let data = Data(raw.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - SwiftUI Extensions + +/// Environment Key fuer ConsentManager +private struct ConsentManagerKey: EnvironmentKey { + static let defaultValue = ConsentManager.shared +} + +extension EnvironmentValues { + public var consentManager: ConsentManager { + get { self[ConsentManagerKey.self] } + set { self[ConsentManagerKey.self] = newValue } + } +} + +/// Banner ViewModifier +public struct ConsentBannerModifier: ViewModifier { + @ObservedObject var consent = ConsentManager.shared + + public func body(content: Content) -> some View { + ZStack { + content + + if consent.isBannerVisible { + ConsentBannerView() + } + } + } +} + +extension View { + /// Fuegt einen Consent-Banner hinzu + public func consentBanner() -> some View { + modifier(ConsentBannerModifier()) + } +} + +// MARK: - Banner View + +/// Default Consent Banner UI +public struct ConsentBannerView: View { + @ObservedObject var consent = ConsentManager.shared + + public init() {} + + public var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 16) { + Text("Datenschutzeinstellungen") + .font(.headline) + + Text("Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + HStack(spacing: 12) { + Button("Alle ablehnen") { + Task { await consent.rejectAll() } + } + .buttonStyle(.bordered) + + Button("Einstellungen") { + consent.showSettings() + } + .buttonStyle(.bordered) + + Button("Alle akzeptieren") { + Task { await consent.acceptAll() } + } + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .padding() + .shadow(radius: 20) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(), value: consent.isBannerVisible) + } +} + +// MARK: - Consent Gate + +/// Zeigt Inhalt nur bei Consent an +public struct ConsentGate: View { + let category: ConsentCategory + let content: () -> Content + let placeholder: () -> Placeholder + + @ObservedObject var consent = ConsentManager.shared + + public init( + category: ConsentCategory, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.category = category + self.content = content + self.placeholder = placeholder + } + + public var body: some View { + if consent.hasConsent(category) { + content() + } else { + placeholder() + } + } +} + +extension ConsentGate where Placeholder == EmptyView { + public init( + category: ConsentCategory, + @ViewBuilder content: @escaping () -> Content + ) { + self.init(category: category, content: content, placeholder: { EmptyView() }) + } +} diff --git a/consent-sdk/src/react/index.tsx b/consent-sdk/src/react/index.tsx new file mode 100644 index 0000000..abaf0bb --- /dev/null +++ b/consent-sdk/src/react/index.tsx @@ -0,0 +1,511 @@ +/** + * React Integration fuer @breakpilot/consent-sdk + * + * @example + * ```tsx + * import { ConsentProvider, useConsent, ConsentBanner } from '@breakpilot/consent-sdk/react'; + * + * function App() { + * return ( + * + * + * + * + * ); + * } + * ``` + */ + +import { + createContext, + useContext, + useEffect, + useState, + useCallback, + useMemo, + type ReactNode, + type FC, +} from 'react'; +import { ConsentManager } from '../core/ConsentManager'; +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, +} from '../types'; + +// ============================================================================= +// Context +// ============================================================================= + +interface ConsentContextValue { + /** ConsentManager Instanz */ + manager: ConsentManager | null; + + /** Aktueller Consent-State */ + consent: ConsentState | null; + + /** Ist SDK initialisiert? */ + isInitialized: boolean; + + /** Wird geladen? */ + isLoading: boolean; + + /** Ist Banner sichtbar? */ + isBannerVisible: boolean; + + /** Wird Consent benoetigt? */ + needsConsent: boolean; + + /** Consent fuer Kategorie pruefen */ + hasConsent: (category: ConsentCategory) => boolean; + + /** Alle akzeptieren */ + acceptAll: () => Promise; + + /** Alle ablehnen */ + rejectAll: () => Promise; + + /** Auswahl speichern */ + saveSelection: (categories: Partial) => Promise; + + /** Banner anzeigen */ + showBanner: () => void; + + /** Banner verstecken */ + hideBanner: () => void; + + /** Einstellungen oeffnen */ + showSettings: () => void; +} + +const ConsentContext = createContext(null); + +// ============================================================================= +// Provider +// ============================================================================= + +interface ConsentProviderProps { + /** SDK-Konfiguration */ + config: ConsentConfig; + + /** Kinder-Komponenten */ + children: ReactNode; +} + +/** + * ConsentProvider - Stellt Consent-Kontext bereit + */ +export const ConsentProvider: FC = ({ + config, + children, +}) => { + const [manager, setManager] = useState(null); + const [consent, setConsent] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isBannerVisible, setIsBannerVisible] = useState(false); + + // Manager erstellen und initialisieren + useEffect(() => { + const consentManager = new ConsentManager(config); + setManager(consentManager); + + // Events abonnieren + const unsubChange = consentManager.on('change', (newConsent) => { + setConsent(newConsent); + }); + + const unsubBannerShow = consentManager.on('banner_show', () => { + setIsBannerVisible(true); + }); + + const unsubBannerHide = consentManager.on('banner_hide', () => { + setIsBannerVisible(false); + }); + + // Initialisieren + consentManager + .init() + .then(() => { + setConsent(consentManager.getConsent()); + setIsInitialized(true); + setIsLoading(false); + setIsBannerVisible(consentManager.isBannerVisible()); + }) + .catch((error) => { + console.error('Failed to initialize ConsentManager:', error); + setIsLoading(false); + }); + + // Cleanup + return () => { + unsubChange(); + unsubBannerShow(); + unsubBannerHide(); + }; + }, [config]); + + // Callback-Funktionen + const hasConsent = useCallback( + (category: ConsentCategory): boolean => { + return manager?.hasConsent(category) ?? category === 'essential'; + }, + [manager] + ); + + const acceptAll = useCallback(async () => { + await manager?.acceptAll(); + }, [manager]); + + const rejectAll = useCallback(async () => { + await manager?.rejectAll(); + }, [manager]); + + const saveSelection = useCallback( + async (categories: Partial) => { + await manager?.setConsent(categories); + manager?.hideBanner(); + }, + [manager] + ); + + const showBanner = useCallback(() => { + manager?.showBanner(); + }, [manager]); + + const hideBanner = useCallback(() => { + manager?.hideBanner(); + }, [manager]); + + const showSettings = useCallback(() => { + manager?.showSettings(); + }, [manager]); + + const needsConsent = useMemo(() => { + return manager?.needsConsent() ?? true; + }, [manager, consent]); + + // Context-Wert + const contextValue = useMemo( + () => ({ + manager, + consent, + isInitialized, + isLoading, + isBannerVisible, + needsConsent, + hasConsent, + acceptAll, + rejectAll, + saveSelection, + showBanner, + hideBanner, + showSettings, + }), + [ + manager, + consent, + isInitialized, + isLoading, + isBannerVisible, + needsConsent, + hasConsent, + acceptAll, + rejectAll, + saveSelection, + showBanner, + hideBanner, + showSettings, + ] + ); + + return ( + + {children} + + ); +}; + +// ============================================================================= +// Hooks +// ============================================================================= + +/** + * useConsent - Hook fuer Consent-Zugriff + * + * @example + * ```tsx + * const { hasConsent, acceptAll, rejectAll } = useConsent(); + * + * if (hasConsent('analytics')) { + * // Analytics laden + * } + * ``` + */ +export function useConsent(): ConsentContextValue; +export function useConsent( + category: ConsentCategory +): ConsentContextValue & { allowed: boolean }; +export function useConsent(category?: ConsentCategory) { + const context = useContext(ConsentContext); + + if (!context) { + throw new Error('useConsent must be used within a ConsentProvider'); + } + + if (category) { + return { + ...context, + allowed: context.hasConsent(category), + }; + } + + return context; +} + +/** + * useConsentManager - Direkter Zugriff auf ConsentManager + */ +export function useConsentManager(): ConsentManager | null { + const context = useContext(ConsentContext); + return context?.manager ?? null; +} + +// ============================================================================= +// Components +// ============================================================================= + +interface ConsentGateProps { + /** Erforderliche Kategorie */ + category: ConsentCategory; + + /** Inhalt bei Consent */ + children: ReactNode; + + /** Inhalt ohne Consent */ + placeholder?: ReactNode; + + /** Fallback waehrend Laden */ + fallback?: ReactNode; +} + +/** + * ConsentGate - Zeigt Inhalt nur bei Consent + * + * @example + * ```tsx + * } + * > + * + * + * ``` + */ +export const ConsentGate: FC = ({ + category, + children, + placeholder = null, + fallback = null, +}) => { + const { hasConsent, isLoading } = useConsent(); + + if (isLoading) { + return <>{fallback}; + } + + if (!hasConsent(category)) { + return <>{placeholder}; + } + + return <>{children}; +}; + +interface ConsentPlaceholderProps { + /** Kategorie */ + category: ConsentCategory; + + /** Custom Nachricht */ + message?: string; + + /** Custom Button-Text */ + buttonText?: string; + + /** Custom Styling */ + className?: string; +} + +/** + * ConsentPlaceholder - Placeholder fuer blockierten Inhalt + */ +export const ConsentPlaceholder: FC = ({ + category, + message, + buttonText, + className = '', +}) => { + const { showSettings } = useConsent(); + + const categoryNames: Record = { + essential: 'Essentielle Cookies', + functional: 'Funktionale Cookies', + analytics: 'Statistik-Cookies', + marketing: 'Marketing-Cookies', + social: 'Social Media-Cookies', + }; + + const defaultMessage = `Dieser Inhalt erfordert ${categoryNames[category]}.`; + + return ( +
+

{message || defaultMessage}

+ +
+ ); +}; + +// ============================================================================= +// Banner Component (Headless) +// ============================================================================= + +interface ConsentBannerRenderProps { + /** Ist Banner sichtbar? */ + isVisible: boolean; + + /** Aktueller Consent */ + consent: ConsentState | null; + + /** Wird Consent benoetigt? */ + needsConsent: boolean; + + /** Alle akzeptieren */ + onAcceptAll: () => void; + + /** Alle ablehnen */ + onRejectAll: () => void; + + /** Auswahl speichern */ + onSaveSelection: (categories: Partial) => void; + + /** Einstellungen oeffnen */ + onShowSettings: () => void; + + /** Banner schliessen */ + onClose: () => void; +} + +interface ConsentBannerProps { + /** Render-Funktion fuer Custom UI */ + render?: (props: ConsentBannerRenderProps) => ReactNode; + + /** Custom Styling */ + className?: string; +} + +/** + * ConsentBanner - Headless Banner-Komponente + * + * Kann mit eigener UI gerendert werden oder nutzt Default-UI. + * + * @example + * ```tsx + * // Mit eigener UI + * ( + * isVisible && ( + *
+ * + * + *
+ * ) + * )} + * /> + * + * // Mit Default-UI + * + * ``` + */ +export const ConsentBanner: FC = ({ render, className }) => { + const { + consent, + isBannerVisible, + needsConsent, + acceptAll, + rejectAll, + saveSelection, + showSettings, + hideBanner, + } = useConsent(); + + const renderProps: ConsentBannerRenderProps = { + isVisible: isBannerVisible, + consent, + needsConsent, + onAcceptAll: acceptAll, + onRejectAll: rejectAll, + onSaveSelection: saveSelection, + onShowSettings: showSettings, + onClose: hideBanner, + }; + + // Custom Render + if (render) { + return <>{render(renderProps)}; + } + + // Default UI + if (!isBannerVisible) { + return null; + } + + return ( +
+
+

Datenschutzeinstellungen

+

+ Wir nutzen Cookies und aehnliche Technologien, um Ihnen ein optimales + Nutzererlebnis zu bieten. +

+ +
+ + + +
+
+
+ ); +}; + +// ============================================================================= +// Exports +// ============================================================================= + +export { ConsentContext }; +export type { ConsentContextValue, ConsentBannerRenderProps }; diff --git a/consent-sdk/src/types/index.ts b/consent-sdk/src/types/index.ts new file mode 100644 index 0000000..f017c07 --- /dev/null +++ b/consent-sdk/src/types/index.ts @@ -0,0 +1,438 @@ +/** + * Consent SDK Types + * + * DSGVO/TTDSG-konforme Typdefinitionen für das Consent Management System. + */ + +// ============================================================================= +// Consent Categories +// ============================================================================= + +/** + * Standard-Consent-Kategorien nach IAB TCF 2.2 + */ +export type ConsentCategory = + | 'essential' // Technisch notwendig (TTDSG § 25 Abs. 2) + | 'functional' // Personalisierung, Komfortfunktionen + | 'analytics' // Anonyme Nutzungsanalyse + | 'marketing' // Werbung, Retargeting + | 'social'; // Social Media Plugins + +/** + * Consent-Status pro Kategorie + */ +export type ConsentCategories = Record; + +/** + * Consent-Status pro Vendor + */ +export type ConsentVendors = Record; + +// ============================================================================= +// Consent State +// ============================================================================= + +/** + * Aktueller Consent-Zustand + */ +export interface ConsentState { + /** Consent pro Kategorie */ + categories: ConsentCategories; + + /** Consent pro Vendor (optional, für granulare Kontrolle) */ + vendors: ConsentVendors; + + /** Zeitstempel der letzten Aenderung */ + timestamp: string; + + /** SDK-Version bei Erstellung */ + version: string; + + /** Eindeutige Consent-ID vom Backend */ + consentId?: string; + + /** Ablaufdatum */ + expiresAt?: string; + + /** IAB TCF String (falls aktiviert) */ + tcfString?: string; +} + +/** + * Minimaler Consent-Input fuer setConsent() + */ +export type ConsentInput = Partial | { + categories?: Partial; + vendors?: ConsentVendors; +}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * UI-Position des Banners + */ +export type BannerPosition = 'bottom' | 'top' | 'center'; + +/** + * Banner-Layout + */ +export type BannerLayout = 'bar' | 'modal' | 'floating'; + +/** + * Farbschema + */ +export type BannerTheme = 'light' | 'dark' | 'auto'; + +/** + * UI-Konfiguration + */ +export interface ConsentUIConfig { + /** Position des Banners */ + position?: BannerPosition; + + /** Layout-Typ */ + layout?: BannerLayout; + + /** Farbschema */ + theme?: BannerTheme; + + /** Pfad zu Custom CSS */ + customCss?: string; + + /** z-index fuer Banner */ + zIndex?: number; + + /** Scroll blockieren bei Modal */ + blockScrollOnModal?: boolean; + + /** Custom Container-ID */ + containerId?: string; +} + +/** + * Consent-Verhaltens-Konfiguration + */ +export interface ConsentBehaviorConfig { + /** Muss Nutzer interagieren? */ + required?: boolean; + + /** "Alle ablehnen" Button sichtbar */ + rejectAllVisible?: boolean; + + /** "Alle akzeptieren" Button sichtbar */ + acceptAllVisible?: boolean; + + /** Einzelne Kategorien waehlbar */ + granularControl?: boolean; + + /** Einzelne Vendors waehlbar */ + vendorControl?: boolean; + + /** Auswahl speichern */ + rememberChoice?: boolean; + + /** Speicherdauer in Tagen */ + rememberDays?: number; + + /** Nur in EU anzeigen (Geo-Targeting) */ + geoTargeting?: boolean; + + /** Erneut nachfragen nach X Tagen */ + recheckAfterDays?: number; +} + +/** + * TCF 2.2 Konfiguration + */ +export interface TCFConfig { + /** TCF aktivieren */ + enabled?: boolean; + + /** CMP ID */ + cmpId?: number; + + /** CMP Version */ + cmpVersion?: number; +} + +/** + * PWA-spezifische Konfiguration + */ +export interface PWAConfig { + /** Offline-Unterstuetzung aktivieren */ + offlineSupport?: boolean; + + /** Bei Reconnect synchronisieren */ + syncOnReconnect?: boolean; + + /** Cache-Strategie */ + cacheStrategy?: 'stale-while-revalidate' | 'network-first' | 'cache-first'; +} + +/** + * Haupt-Konfiguration fuer ConsentManager + */ +export interface ConsentConfig { + // Pflichtfelder + /** API-Endpunkt fuer Consent-Backend */ + apiEndpoint: string; + + /** Site-ID */ + siteId: string; + + // Sprache + /** Sprache (ISO 639-1) */ + language?: string; + + /** Fallback-Sprache */ + fallbackLanguage?: string; + + // UI + /** UI-Konfiguration */ + ui?: ConsentUIConfig; + + // Verhalten + /** Consent-Verhaltens-Konfiguration */ + consent?: ConsentBehaviorConfig; + + // Kategorien + /** Aktive Kategorien */ + categories?: ConsentCategory[]; + + // TCF + /** TCF 2.2 Konfiguration */ + tcf?: TCFConfig; + + // PWA + /** PWA-Konfiguration */ + pwa?: PWAConfig; + + // Callbacks + /** Callback bei Consent-Aenderung */ + onConsentChange?: (consent: ConsentState) => void; + + /** Callback wenn Banner angezeigt wird */ + onBannerShow?: () => void; + + /** Callback wenn Banner geschlossen wird */ + onBannerHide?: () => void; + + /** Callback bei Fehler */ + onError?: (error: Error) => void; + + // Debug + /** Debug-Modus aktivieren */ + debug?: boolean; +} + +// ============================================================================= +// Vendor Configuration +// ============================================================================= + +/** + * Cookie-Information + */ +export interface CookieInfo { + /** Cookie-Name */ + name: string; + + /** Cookie-Domain */ + domain: string; + + /** Ablaufzeit (z.B. "2 Jahre", "Session") */ + expiration: string; + + /** Speichertyp */ + type: 'http' | 'localStorage' | 'sessionStorage' | 'indexedDB'; + + /** Beschreibung */ + description: string; +} + +/** + * Vendor-Definition + */ +export interface ConsentVendor { + /** Eindeutige Vendor-ID */ + id: string; + + /** Anzeigename */ + name: string; + + /** Kategorie */ + category: ConsentCategory; + + /** IAB TCF Purposes (falls relevant) */ + purposes?: number[]; + + /** Legitimate Interests */ + legitimateInterests?: number[]; + + /** Cookie-Liste */ + cookies: CookieInfo[]; + + /** Link zur Datenschutzerklaerung */ + privacyPolicyUrl: string; + + /** Datenaufbewahrung */ + dataRetention?: string; + + /** Datentransfer (z.B. "USA (EU-US DPF)", "EU") */ + dataTransfer?: string; +} + +// ============================================================================= +// API Types +// ============================================================================= + +/** + * API-Antwort fuer Consent-Erstellung + */ +export interface ConsentAPIResponse { + consentId: string; + timestamp: string; + expiresAt: string; + version: string; +} + +/** + * API-Antwort fuer Site-Konfiguration + */ +export interface SiteConfigResponse { + siteId: string; + siteName: string; + categories: CategoryConfig[]; + ui: ConsentUIConfig; + legal: LegalConfig; + tcf?: TCFConfig; +} + +/** + * Kategorie-Konfiguration vom Server + */ +export interface CategoryConfig { + id: ConsentCategory; + name: Record; + description: Record; + required: boolean; + vendors: ConsentVendor[]; +} + +/** + * Rechtliche Konfiguration + */ +export interface LegalConfig { + privacyPolicyUrl: string; + imprintUrl: string; + dpo?: { + name: string; + email: string; + }; +} + +// ============================================================================= +// Events +// ============================================================================= + +/** + * Event-Typen + */ +export type ConsentEventType = + | 'init' + | 'change' + | 'accept_all' + | 'reject_all' + | 'save_selection' + | 'banner_show' + | 'banner_hide' + | 'settings_open' + | 'settings_close' + | 'vendor_enable' + | 'vendor_disable' + | 'error'; + +/** + * Event-Listener Callback + */ +export type ConsentEventCallback = (data: T) => void; + +/** + * Event-Daten fuer verschiedene Events + */ +export type ConsentEventData = { + init: ConsentState | null; + change: ConsentState; + accept_all: ConsentState; + reject_all: ConsentState; + save_selection: ConsentState; + banner_show: undefined; + banner_hide: undefined; + settings_open: undefined; + settings_close: undefined; + vendor_enable: string; + vendor_disable: string; + error: Error; +}; + +// ============================================================================= +// Storage +// ============================================================================= + +/** + * Storage-Adapter Interface + */ +export interface ConsentStorageAdapter { + /** Consent laden */ + get(): ConsentState | null; + + /** Consent speichern */ + set(consent: ConsentState): void; + + /** Consent loeschen */ + clear(): void; + + /** Pruefen ob Consent existiert */ + exists(): boolean; +} + +// ============================================================================= +// Translations +// ============================================================================= + +/** + * Uebersetzungsstruktur + */ +export interface ConsentTranslations { + title: string; + description: string; + acceptAll: string; + rejectAll: string; + settings: string; + saveSelection: string; + close: string; + categories: { + [K in ConsentCategory]: { + name: string; + description: string; + }; + }; + footer: { + privacyPolicy: string; + imprint: string; + cookieDetails: string; + }; + accessibility: { + closeButton: string; + categoryToggle: string; + requiredCategory: string; + }; +} + +/** + * Alle unterstuetzten Sprachen + */ +export type SupportedLanguage = + | 'de' | 'en' | 'fr' | 'es' | 'it' | 'nl' | 'pl' | 'pt' + | 'cs' | 'da' | 'el' | 'fi' | 'hu' | 'ro' | 'sk' | 'sl' | 'sv'; diff --git a/consent-sdk/src/utils/EventEmitter.test.ts b/consent-sdk/src/utils/EventEmitter.test.ts new file mode 100644 index 0000000..b8c29eb --- /dev/null +++ b/consent-sdk/src/utils/EventEmitter.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from './EventEmitter'; + +interface TestEvents { + test: string; + count: number; + data: { value: string }; +} + +describe('EventEmitter', () => { + let emitter: EventEmitter; + + beforeEach(() => { + emitter = new EventEmitter(); + }); + + describe('on', () => { + it('should register an event listener', () => { + const callback = vi.fn(); + emitter.on('test', callback); + + emitter.emit('test', 'hello'); + + expect(callback).toHaveBeenCalledWith('hello'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should return an unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = emitter.on('test', callback); + + emitter.emit('test', 'first'); + unsubscribe(); + emitter.emit('test', 'second'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('first'); + }); + + it('should allow multiple listeners for the same event', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + emitter.on('test', callback1); + emitter.on('test', callback2); + emitter.emit('test', 'value'); + + expect(callback1).toHaveBeenCalledWith('value'); + expect(callback2).toHaveBeenCalledWith('value'); + }); + }); + + describe('off', () => { + it('should remove an event listener', () => { + const callback = vi.fn(); + emitter.on('test', callback); + + emitter.emit('test', 'first'); + emitter.off('test', callback); + emitter.emit('test', 'second'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not throw when removing non-existent listener', () => { + const callback = vi.fn(); + expect(() => emitter.off('test', callback)).not.toThrow(); + }); + }); + + describe('emit', () => { + it('should call all listeners with the data', () => { + const callback = vi.fn(); + emitter.on('data', callback); + + emitter.emit('data', { value: 'test' }); + + expect(callback).toHaveBeenCalledWith({ value: 'test' }); + }); + + it('should not throw when emitting to no listeners', () => { + expect(() => emitter.emit('test', 'value')).not.toThrow(); + }); + + it('should catch errors in listeners and continue', () => { + const errorCallback = vi.fn(() => { + throw new Error('Test error'); + }); + const successCallback = vi.fn(); + + emitter.on('test', errorCallback); + emitter.on('test', successCallback); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + emitter.emit('test', 'value'); + + expect(errorCallback).toHaveBeenCalled(); + expect(successCallback).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe('once', () => { + it('should call listener only once', () => { + const callback = vi.fn(); + emitter.once('test', callback); + + emitter.emit('test', 'first'); + emitter.emit('test', 'second'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('first'); + }); + + it('should return an unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = emitter.once('test', callback); + + unsubscribe(); + emitter.emit('test', 'value'); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should remove all listeners', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + emitter.on('test', callback1); + emitter.on('count', callback2); + emitter.clear(); + + emitter.emit('test', 'value'); + emitter.emit('count', 42); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + }); + + describe('clearEvent', () => { + it('should remove all listeners for a specific event', () => { + const testCallback = vi.fn(); + const countCallback = vi.fn(); + + emitter.on('test', testCallback); + emitter.on('count', countCallback); + emitter.clearEvent('test'); + + emitter.emit('test', 'value'); + emitter.emit('count', 42); + + expect(testCallback).not.toHaveBeenCalled(); + expect(countCallback).toHaveBeenCalledWith(42); + }); + }); + + describe('listenerCount', () => { + it('should return the number of listeners for an event', () => { + expect(emitter.listenerCount('test')).toBe(0); + + emitter.on('test', () => {}); + expect(emitter.listenerCount('test')).toBe(1); + + emitter.on('test', () => {}); + expect(emitter.listenerCount('test')).toBe(2); + }); + + it('should return 0 for events with no listeners', () => { + expect(emitter.listenerCount('count')).toBe(0); + }); + }); +}); diff --git a/consent-sdk/src/utils/EventEmitter.ts b/consent-sdk/src/utils/EventEmitter.ts new file mode 100644 index 0000000..f3d4216 --- /dev/null +++ b/consent-sdk/src/utils/EventEmitter.ts @@ -0,0 +1,89 @@ +/** + * EventEmitter - Typsicherer Event-Handler + */ + +type EventCallback = (data: T) => void; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class EventEmitter = Record> { + private listeners: Map>> = new Map(); + + /** + * Event-Listener registrieren + * @returns Unsubscribe-Funktion + */ + on( + event: K, + callback: EventCallback + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + + this.listeners.get(event)!.add(callback as EventCallback); + + // Unsubscribe-Funktion zurueckgeben + return () => this.off(event, callback); + } + + /** + * Event-Listener entfernen + */ + off( + event: K, + callback: EventCallback + ): void { + this.listeners.get(event)?.delete(callback as EventCallback); + } + + /** + * Event emittieren + */ + emit(event: K, data: Events[K]): void { + this.listeners.get(event)?.forEach((callback) => { + try { + callback(data); + } catch (error) { + console.error(`Error in event handler for ${String(event)}:`, error); + } + }); + } + + /** + * Einmaligen Listener registrieren + */ + once( + event: K, + callback: EventCallback + ): () => void { + const wrapper = (data: Events[K]) => { + this.off(event, wrapper); + callback(data); + }; + + return this.on(event, wrapper); + } + + /** + * Alle Listener entfernen + */ + clear(): void { + this.listeners.clear(); + } + + /** + * Alle Listener fuer ein Event entfernen + */ + clearEvent(event: K): void { + this.listeners.delete(event); + } + + /** + * Anzahl Listener fuer ein Event + */ + listenerCount(event: K): number { + return this.listeners.get(event)?.size ?? 0; + } +} + +export default EventEmitter; diff --git a/consent-sdk/src/utils/fingerprint.test.ts b/consent-sdk/src/utils/fingerprint.test.ts new file mode 100644 index 0000000..47e6681 --- /dev/null +++ b/consent-sdk/src/utils/fingerprint.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { generateFingerprint, generateFingerprintSync } from './fingerprint'; + +describe('fingerprint', () => { + describe('generateFingerprint', () => { + it('should generate a fingerprint with fp_ prefix', async () => { + const fingerprint = await generateFingerprint(); + + expect(fingerprint).toMatch(/^fp_[a-f0-9]{32}$/); + }); + + it('should generate consistent fingerprints for same environment', async () => { + const fp1 = await generateFingerprint(); + const fp2 = await generateFingerprint(); + + expect(fp1).toBe(fp2); + }); + + it('should include browser detection in fingerprint components', async () => { + // Chrome is in the mocked userAgent + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + }); + }); + + describe('generateFingerprintSync', () => { + it('should generate a fingerprint with fp_ prefix', () => { + const fingerprint = generateFingerprintSync(); + + expect(fingerprint).toMatch(/^fp_[a-f0-9]+$/); + }); + + it('should be consistent for same environment', () => { + const fp1 = generateFingerprintSync(); + const fp2 = generateFingerprintSync(); + + expect(fp1).toBe(fp2); + }); + }); + + describe('environment variations', () => { + it('should detect screen categories correctly', async () => { + // Default is 1920px (FHD) + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + }); + + it('should handle touch detection', async () => { + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + }); + + it('should handle Do Not Track', async () => { + Object.defineProperty(navigator, 'doNotTrack', { + value: '1', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'doNotTrack', { + value: null, + configurable: true, + }); + }); + }); + + describe('browser detection', () => { + it('should detect Firefox', async () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }); + }); + + it('should detect Safari', async () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }); + }); + + it('should detect Edge', async () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }); + }); + }); + + describe('platform detection', () => { + it('should detect Windows', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + }); + + it('should detect Linux', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'Linux x86_64', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + }); + + it('should detect iOS', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'iPhone', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + }); + }); + + describe('screen categories', () => { + it('should detect 4K screens', async () => { + Object.defineProperty(window, 'screen', { + value: { width: 3840, height: 2160, colorDepth: 24 }, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(window, 'screen', { + value: { width: 1920, height: 1080, colorDepth: 24 }, + configurable: true, + }); + }); + + it('should detect tablet screens', async () => { + Object.defineProperty(window, 'screen', { + value: { width: 1024, height: 768, colorDepth: 24 }, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(window, 'screen', { + value: { width: 1920, height: 1080, colorDepth: 24 }, + configurable: true, + }); + }); + + it('should detect mobile screens', async () => { + Object.defineProperty(window, 'screen', { + value: { width: 375, height: 812, colorDepth: 24 }, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(window, 'screen', { + value: { width: 1920, height: 1080, colorDepth: 24 }, + configurable: true, + }); + }); + }); +}); diff --git a/consent-sdk/src/utils/fingerprint.ts b/consent-sdk/src/utils/fingerprint.ts new file mode 100644 index 0000000..4815c64 --- /dev/null +++ b/consent-sdk/src/utils/fingerprint.ts @@ -0,0 +1,176 @@ +/** + * Device Fingerprinting - Datenschutzkonform + * + * Generiert einen anonymen Fingerprint OHNE: + * - Canvas Fingerprinting + * - WebGL Fingerprinting + * - Audio Fingerprinting + * - Hardware-spezifische IDs + * + * Verwendet nur: + * - User Agent + * - Sprache + * - Bildschirmaufloesung + * - Zeitzone + * - Platform + */ + +/** + * Fingerprint-Komponenten sammeln + */ +function getComponents(): string[] { + if (typeof window === 'undefined') { + return ['server']; + } + + const components: string[] = []; + + // User Agent (anonymisiert) + try { + // Nur Browser-Familie, nicht vollstaendiger UA + const ua = navigator.userAgent; + if (ua.includes('Chrome')) components.push('chrome'); + else if (ua.includes('Firefox')) components.push('firefox'); + else if (ua.includes('Safari')) components.push('safari'); + else if (ua.includes('Edge')) components.push('edge'); + else components.push('other'); + } catch { + components.push('unknown-browser'); + } + + // Sprache + try { + components.push(navigator.language || 'unknown-lang'); + } catch { + components.push('unknown-lang'); + } + + // Bildschirm-Kategorie (nicht exakte Aufloesung) + try { + const width = window.screen.width; + if (width >= 2560) components.push('4k'); + else if (width >= 1920) components.push('fhd'); + else if (width >= 1366) components.push('hd'); + else if (width >= 768) components.push('tablet'); + else components.push('mobile'); + } catch { + components.push('unknown-screen'); + } + + // Farbtiefe (grob) + try { + const depth = window.screen.colorDepth; + if (depth >= 24) components.push('deep-color'); + else components.push('standard-color'); + } catch { + components.push('unknown-color'); + } + + // Zeitzone (nur Offset, nicht Name) + try { + const offset = new Date().getTimezoneOffset(); + const hours = Math.floor(Math.abs(offset) / 60); + const sign = offset <= 0 ? '+' : '-'; + components.push(`tz${sign}${hours}`); + } catch { + components.push('unknown-tz'); + } + + // Platform-Kategorie + try { + const platform = navigator.platform?.toLowerCase() || ''; + if (platform.includes('mac')) components.push('mac'); + else if (platform.includes('win')) components.push('win'); + else if (platform.includes('linux')) components.push('linux'); + else if (platform.includes('iphone') || platform.includes('ipad')) + components.push('ios'); + else if (platform.includes('android')) components.push('android'); + else components.push('other-platform'); + } catch { + components.push('unknown-platform'); + } + + // Touch-Faehigkeit + try { + if ('ontouchstart' in window || navigator.maxTouchPoints > 0) { + components.push('touch'); + } else { + components.push('no-touch'); + } + } catch { + components.push('unknown-touch'); + } + + // Do Not Track (als Datenschutz-Signal) + try { + if (navigator.doNotTrack === '1') { + components.push('dnt'); + } + } catch { + // Ignorieren + } + + return components; +} + +/** + * SHA-256 Hash (async, nutzt SubtleCrypto) + */ +async function sha256(message: string): Promise { + if (typeof window === 'undefined' || !window.crypto?.subtle) { + // Fallback fuer Server/alte Browser + return simpleHash(message); + } + + try { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } catch { + return simpleHash(message); + } +} + +/** + * Fallback Hash-Funktion (djb2) + */ +function simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +/** + * Datenschutzkonformen Fingerprint generieren + * + * Der Fingerprint ist: + * - Nicht eindeutig (viele Nutzer teilen sich denselben) + * - Nicht persistent (aendert sich bei Browser-Updates) + * - Nicht invasiv (keine Canvas/WebGL/Audio) + * - Anonymisiert (SHA-256 Hash) + */ +export async function generateFingerprint(): Promise { + const components = getComponents(); + const combined = components.join('|'); + const hash = await sha256(combined); + + // Prefix fuer Identifikation + return `fp_${hash.substring(0, 32)}`; +} + +/** + * Synchrone Version (mit einfachem Hash) + */ +export function generateFingerprintSync(): string { + const components = getComponents(); + const combined = components.join('|'); + const hash = simpleHash(combined); + + return `fp_${hash}`; +} + +export default generateFingerprint; diff --git a/consent-sdk/src/version.ts b/consent-sdk/src/version.ts new file mode 100644 index 0000000..11fe8da --- /dev/null +++ b/consent-sdk/src/version.ts @@ -0,0 +1,6 @@ +/** + * SDK Version + */ +export const SDK_VERSION = '1.0.0'; + +export default SDK_VERSION; diff --git a/consent-sdk/src/vue/index.ts b/consent-sdk/src/vue/index.ts new file mode 100644 index 0000000..9f157f7 --- /dev/null +++ b/consent-sdk/src/vue/index.ts @@ -0,0 +1,511 @@ +/** + * Vue 3 Integration fuer @breakpilot/consent-sdk + * + * @example + * ```vue + * + * + * + * ``` + */ + +import { + ref, + computed, + readonly, + inject, + provide, + onMounted, + onUnmounted, + defineComponent, + h, + type Ref, + type InjectionKey, + type PropType, +} from 'vue'; +import { ConsentManager } from '../core/ConsentManager'; +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, +} from '../types'; + +// ============================================================================= +// Injection Key +// ============================================================================= + +const CONSENT_KEY: InjectionKey = Symbol('consent'); + +// ============================================================================= +// Types +// ============================================================================= + +interface ConsentContext { + manager: Ref; + consent: Ref; + isInitialized: Ref; + isLoading: Ref; + isBannerVisible: Ref; + needsConsent: Ref; + hasConsent: (category: ConsentCategory) => boolean; + acceptAll: () => Promise; + rejectAll: () => Promise; + saveSelection: (categories: Partial) => Promise; + showBanner: () => void; + hideBanner: () => void; + showSettings: () => void; +} + +// ============================================================================= +// Composable: useConsent +// ============================================================================= + +/** + * Haupt-Composable fuer Consent-Zugriff + * + * @example + * ```vue + * + * ``` + */ +export function useConsent(): ConsentContext { + const context = inject(CONSENT_KEY); + + if (!context) { + throw new Error( + 'useConsent() must be used within a component that has called provideConsent() or is wrapped in ConsentProvider' + ); + } + + return context; +} + +/** + * Consent-Provider einrichten (in App.vue aufrufen) + * + * @example + * ```vue + * + * ``` + */ +export function provideConsent(config: ConsentConfig): ConsentContext { + const manager = ref(null); + const consent = ref(null); + const isInitialized = ref(false); + const isLoading = ref(true); + const isBannerVisible = ref(false); + + const needsConsent = computed(() => { + return manager.value?.needsConsent() ?? true; + }); + + // Initialisierung + onMounted(async () => { + const consentManager = new ConsentManager(config); + manager.value = consentManager; + + // Events abonnieren + const unsubChange = consentManager.on('change', (newConsent) => { + consent.value = newConsent; + }); + + const unsubBannerShow = consentManager.on('banner_show', () => { + isBannerVisible.value = true; + }); + + const unsubBannerHide = consentManager.on('banner_hide', () => { + isBannerVisible.value = false; + }); + + try { + await consentManager.init(); + consent.value = consentManager.getConsent(); + isInitialized.value = true; + isBannerVisible.value = consentManager.isBannerVisible(); + } catch (error) { + console.error('Failed to initialize ConsentManager:', error); + } finally { + isLoading.value = false; + } + + // Cleanup bei Unmount + onUnmounted(() => { + unsubChange(); + unsubBannerShow(); + unsubBannerHide(); + }); + }); + + // Methoden + const hasConsent = (category: ConsentCategory): boolean => { + return manager.value?.hasConsent(category) ?? category === 'essential'; + }; + + const acceptAll = async (): Promise => { + await manager.value?.acceptAll(); + }; + + const rejectAll = async (): Promise => { + await manager.value?.rejectAll(); + }; + + const saveSelection = async (categories: Partial): Promise => { + await manager.value?.setConsent(categories); + manager.value?.hideBanner(); + }; + + const showBanner = (): void => { + manager.value?.showBanner(); + }; + + const hideBanner = (): void => { + manager.value?.hideBanner(); + }; + + const showSettings = (): void => { + manager.value?.showSettings(); + }; + + const context: ConsentContext = { + manager: readonly(manager) as Ref, + consent: readonly(consent) as Ref, + isInitialized: readonly(isInitialized), + isLoading: readonly(isLoading), + isBannerVisible: readonly(isBannerVisible), + needsConsent, + hasConsent, + acceptAll, + rejectAll, + saveSelection, + showBanner, + hideBanner, + showSettings, + }; + + provide(CONSENT_KEY, context); + + return context; +} + +// ============================================================================= +// Components +// ============================================================================= + +/** + * ConsentProvider - Wrapper-Komponente + * + * @example + * ```vue + * + * + * + * ``` + */ +export const ConsentProvider = defineComponent({ + name: 'ConsentProvider', + props: { + config: { + type: Object as PropType, + required: true, + }, + }, + setup(props, { slots }) { + provideConsent(props.config); + return () => slots.default?.(); + }, +}); + +/** + * ConsentGate - Zeigt Inhalt nur bei Consent + * + * @example + * ```vue + * + * + * + * + * ``` + */ +export const ConsentGate = defineComponent({ + name: 'ConsentGate', + props: { + category: { + type: String as PropType, + required: true, + }, + }, + setup(props, { slots }) { + const { hasConsent, isLoading } = useConsent(); + + return () => { + if (isLoading.value) { + return slots.fallback?.() ?? null; + } + + if (!hasConsent(props.category)) { + return slots.placeholder?.() ?? null; + } + + return slots.default?.(); + }; + }, +}); + +/** + * ConsentPlaceholder - Placeholder fuer blockierten Inhalt + * + * @example + * ```vue + * + * ``` + */ +export const ConsentPlaceholder = defineComponent({ + name: 'ConsentPlaceholder', + props: { + category: { + type: String as PropType, + required: true, + }, + message: { + type: String, + default: '', + }, + buttonText: { + type: String, + default: 'Cookie-Einstellungen öffnen', + }, + }, + setup(props) { + const { showSettings } = useConsent(); + + const categoryNames: Record = { + essential: 'Essentielle Cookies', + functional: 'Funktionale Cookies', + analytics: 'Statistik-Cookies', + marketing: 'Marketing-Cookies', + social: 'Social Media-Cookies', + }; + + const displayMessage = computed(() => { + return props.message || `Dieser Inhalt erfordert ${categoryNames[props.category]}.`; + }); + + return () => + h('div', { class: 'bp-consent-placeholder' }, [ + h('p', displayMessage.value), + h( + 'button', + { + type: 'button', + onClick: showSettings, + }, + props.buttonText + ), + ]); + }, +}); + +/** + * ConsentBanner - Cookie-Banner Komponente + * + * @example + * ```vue + * + * + * + * ``` + */ +export const ConsentBanner = defineComponent({ + name: 'ConsentBanner', + setup(_, { slots }) { + const { + consent, + isBannerVisible, + needsConsent, + acceptAll, + rejectAll, + saveSelection, + showSettings, + hideBanner, + } = useConsent(); + + const slotProps = computed(() => ({ + isVisible: isBannerVisible.value, + consent: consent.value, + needsConsent: needsConsent.value, + onAcceptAll: acceptAll, + onRejectAll: rejectAll, + onSaveSelection: saveSelection, + onShowSettings: showSettings, + onClose: hideBanner, + })); + + return () => { + // Custom Slot + if (slots.default) { + return slots.default(slotProps.value); + } + + // Default UI + if (!isBannerVisible.value) { + return null; + } + + return h( + 'div', + { + class: 'bp-consent-banner', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': 'Cookie-Einstellungen', + }, + [ + h('div', { class: 'bp-consent-banner-content' }, [ + h('h2', 'Datenschutzeinstellungen'), + h( + 'p', + 'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.' + ), + h('div', { class: 'bp-consent-banner-actions' }, [ + h( + 'button', + { + type: 'button', + class: 'bp-consent-btn bp-consent-btn-reject', + onClick: rejectAll, + }, + 'Alle ablehnen' + ), + h( + 'button', + { + type: 'button', + class: 'bp-consent-btn bp-consent-btn-settings', + onClick: showSettings, + }, + 'Einstellungen' + ), + h( + 'button', + { + type: 'button', + class: 'bp-consent-btn bp-consent-btn-accept', + onClick: acceptAll, + }, + 'Alle akzeptieren' + ), + ]), + ]), + ] + ); + }; + }, +}); + +// ============================================================================= +// Plugin +// ============================================================================= + +/** + * Vue Plugin fuer globale Installation + * + * @example + * ```ts + * import { createApp } from 'vue'; + * import { ConsentPlugin } from '@breakpilot/consent-sdk/vue'; + * + * const app = createApp(App); + * app.use(ConsentPlugin, { + * apiEndpoint: 'https://consent.example.com/api/v1', + * siteId: 'site_abc123', + * }); + * ``` + */ +export const ConsentPlugin = { + install(app: { provide: (key: symbol | string, value: unknown) => void }, config: ConsentConfig) { + const manager = new ConsentManager(config); + const consent = ref(null); + const isInitialized = ref(false); + const isLoading = ref(true); + const isBannerVisible = ref(false); + + // Initialisieren + manager.init().then(() => { + consent.value = manager.getConsent(); + isInitialized.value = true; + isLoading.value = false; + isBannerVisible.value = manager.isBannerVisible(); + }); + + // Events + manager.on('change', (newConsent) => { + consent.value = newConsent; + }); + manager.on('banner_show', () => { + isBannerVisible.value = true; + }); + manager.on('banner_hide', () => { + isBannerVisible.value = false; + }); + + const context: ConsentContext = { + manager: ref(manager) as Ref, + consent: consent as Ref, + isInitialized, + isLoading, + isBannerVisible, + needsConsent: computed(() => manager.needsConsent()), + hasConsent: (category: ConsentCategory) => manager.hasConsent(category), + acceptAll: () => manager.acceptAll(), + rejectAll: () => manager.rejectAll(), + saveSelection: async (categories: Partial) => { + await manager.setConsent(categories); + manager.hideBanner(); + }, + showBanner: () => manager.showBanner(), + hideBanner: () => manager.hideBanner(), + showSettings: () => manager.showSettings(), + }; + + app.provide(CONSENT_KEY, context); + }, +}; + +// ============================================================================= +// Exports +// ============================================================================= + +export { CONSENT_KEY }; +export type { ConsentContext }; diff --git a/consent-sdk/test-setup.ts b/consent-sdk/test-setup.ts new file mode 100644 index 0000000..cbd3683 --- /dev/null +++ b/consent-sdk/test-setup.ts @@ -0,0 +1,137 @@ +import { vi } from 'vitest'; + +// Mock localStorage +const localStorageMock = { + store: {} as Record, + getItem: vi.fn((key: string) => localStorageMock.store[key] || null), + setItem: vi.fn((key: string, value: string) => { + localStorageMock.store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete localStorageMock.store[key]; + }), + clear: vi.fn(() => { + localStorageMock.store = {}; + }), + get length() { + return Object.keys(localStorageMock.store).length; + }, + key: vi.fn((index: number) => Object.keys(localStorageMock.store)[index] || null), +}; + +vi.stubGlobal('localStorage', localStorageMock); + +// Mock fetch +vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + }) + ) +); + +// Mock crypto for fingerprinting +const cryptoMock = { + subtle: { + digest: vi.fn(async (_algorithm: string, data: ArrayBuffer) => { + // Simple mock hash - returns predictable data + const view = new Uint8Array(data); + const hash = new Uint8Array(32); + for (let i = 0; i < hash.length; i++) { + hash[i] = (view[i % view.length] || 0) ^ (i * 7); + } + return hash.buffer; + }), + }, + getRandomValues: vi.fn((arr: T): T => { + if (arr instanceof Uint8Array) { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + } + return arr; + }), +}; + +vi.stubGlobal('crypto', cryptoMock); + +// Mock document.cookie +let documentCookie = ''; +Object.defineProperty(document, 'cookie', { + get: () => documentCookie, + set: (value: string) => { + documentCookie = value; + }, + configurable: true, +}); + +// Mock navigator properties +Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, +}); + +Object.defineProperty(navigator, 'language', { + value: 'de-DE', + configurable: true, +}); + +Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, +}); + +Object.defineProperty(navigator, 'doNotTrack', { + value: null, + configurable: true, +}); + +Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, +}); + +// Mock screen +Object.defineProperty(window, 'screen', { + value: { + width: 1920, + height: 1080, + colorDepth: 24, + pixelDepth: 24, + availWidth: 1920, + availHeight: 1040, + orientation: { type: 'landscape-primary', angle: 0 }, + }, + configurable: true, +}); + +// Mock location +Object.defineProperty(window, 'location', { + value: { + protocol: 'https:', + hostname: 'localhost', + port: '3000', + pathname: '/', + href: 'https://localhost:3000/', + }, + writable: true, + configurable: true, +}); + +// Reset mocks before each test +beforeEach(() => { + localStorageMock.store = {}; + localStorageMock.getItem.mockClear(); + localStorageMock.setItem.mockClear(); + localStorageMock.removeItem.mockClear(); + localStorageMock.clear.mockClear(); + documentCookie = ''; + vi.clearAllMocks(); +}); + +// Export for use in tests +export { localStorageMock }; diff --git a/consent-sdk/tsconfig.json b/consent-sdk/tsconfig.json new file mode 100644 index 0000000..eb59e2d --- /dev/null +++ b/consent-sdk/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx", + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/consent-sdk/tsup.config.ts b/consent-sdk/tsup.config.ts new file mode 100644 index 0000000..e9be8fa --- /dev/null +++ b/consent-sdk/tsup.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + // Main entry + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + clean: true, + outDir: 'dist', + external: ['react', 'react-dom', 'vue'], + }, + // React entry + { + entry: ['src/react/index.tsx'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/react', + external: ['react', 'react-dom'], + esbuildOptions(options) { + options.jsx = 'automatic'; + }, + }, + // Vue entry + { + entry: ['src/vue/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/vue', + external: ['vue'], + }, + // Angular entry + { + entry: ['src/angular/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/angular', + external: ['@angular/core', '@angular/common', 'rxjs'], + }, +]); diff --git a/consent-sdk/vitest.config.ts b/consent-sdk/vitest.config.ts new file mode 100644 index 0000000..f4829fe --- /dev/null +++ b/consent-sdk/vitest.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./test-setup.ts'], + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.d.ts', + 'test-setup.ts', + 'vitest.config.ts', + // Framework integrations require separate component testing + 'src/react/**', + 'src/vue/**', + 'src/angular/**', + // Re-export index files + 'src/index.ts', + 'src/core/index.ts', + ], + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 80, + }, + }, + }, +}); diff --git a/developer-portal/.dockerignore b/developer-portal/.dockerignore new file mode 100644 index 0000000..e421be9 --- /dev/null +++ b/developer-portal/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.gitignore +README.md +*.log +.env.local +.env.*.local diff --git a/developer-portal/Dockerfile b/developer-portal/Dockerfile new file mode 100644 index 0000000..20267ee --- /dev/null +++ b/developer-portal/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Set to production +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE 3000 + +# Set hostname +ENV HOSTNAME="0.0.0.0" + +# Start the application +CMD ["node", "server.js"] diff --git a/developer-portal/app/api/export/page.tsx b/developer-portal/app/api/export/page.tsx new file mode 100644 index 0000000..368768d --- /dev/null +++ b/developer-portal/app/api/export/page.tsx @@ -0,0 +1,271 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function ExportApiPage() { + return ( + +

Uebersicht

+

+ Die Export API ermoeglicht den Download aller Compliance-Daten in + verschiedenen Formaten fuer Audits, Dokumentation und Archivierung. +

+ +

Unterstuetzte Formate

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FormatBeschreibungUse Case
jsonKompletter State als JSONBackup, Migration, API-Integration
pdfFormatierter PDF-ReportAudits, Management-Reports
zipAlle Dokumente als ZIP-ArchivVollstaendige Dokumentation
+
+ +

GET /export

+

Exportiert den aktuellen State im gewuenschten Format.

+ +

Query-Parameter

+ + +

JSON Export

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=json&tenantId=your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -o compliance-export.json`} + + +

Response

+ +{`{ + "exportedAt": "2026-02-04T12:00:00Z", + "version": "1.0.0", + "tenantId": "your-tenant-id", + "state": { + "currentPhase": 2, + "currentStep": "dsfa", + "completedSteps": [...], + "useCases": [...], + "risks": [...], + "controls": [...], + "dsfa": {...}, + "toms": [...], + "vvt": [...] + }, + "meta": { + "completionPercentage": 75, + "lastModified": "2026-02-04T11:55:00Z" + } +}`} + + +

PDF Export

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=pdf&tenantId=your-tenant-id§ions=dsfa,toms" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -o compliance-report.pdf`} + + +

PDF Inhalt

+

Das generierte PDF enthaelt:

+
    +
  • Deckblatt mit Tenant-Info und Exportdatum
  • +
  • Inhaltsverzeichnis
  • +
  • Executive Summary mit Fortschritt
  • +
  • Use Case Uebersicht
  • +
  • Risikoanalyse mit Matrix-Visualisierung
  • +
  • DSFA (falls generiert)
  • +
  • TOM-Katalog
  • +
  • VVT-Auszug
  • +
  • Checkpoint-Status
  • +
+ + + Das PDF folgt einem professionellen Audit-Layout mit Corporate Design. + Enterprise-Kunden koennen ein Custom-Logo und Farbschema konfigurieren. + + +

ZIP Export

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -o compliance-export.zip`} + + +

ZIP Struktur

+ +{`compliance-export/ +├── README.md +├── state.json # Kompletter State +├── summary.pdf # Executive Summary +├── use-cases/ +│ ├── uc-1-ki-analyse.json +│ └── uc-2-chatbot.json +├── risks/ +│ ├── risk-matrix.pdf +│ └── risks.json +├── documents/ +│ ├── dsfa.pdf +│ ├── dsfa.json +│ ├── toms.pdf +│ ├── toms.json +│ ├── vvt.pdf +│ └── vvt.json +├── checkpoints/ +│ └── checkpoint-status.json +└── audit-trail/ + └── changes.json`} + + +

SDK Integration

+ +{`import { useSDK, exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk' + +// Option 1: Ueber den Hook +function ExportButton() { + const { exportState } = useSDK() + + const handlePDFExport = async () => { + const blob = await exportState('pdf') + downloadExport(blob, 'compliance-report.pdf') + } + + const handleZIPExport = async () => { + const blob = await exportState('zip') + downloadExport(blob, 'compliance-export.zip') + } + + const handleJSONExport = async () => { + const blob = await exportState('json') + downloadExport(blob, 'compliance-state.json') + } + + return ( +
+ + + +
+ ) +} + +// Option 2: Direkte Funktionen +async function exportManually(state: SDKState) { + // PDF generieren + const pdfBlob = await exportToPDF(state) + downloadExport(pdfBlob, \`compliance-\${Date.now()}.pdf\`) + + // ZIP generieren + const zipBlob = await exportToZIP(state) + downloadExport(zipBlob, \`compliance-\${Date.now()}.zip\`) +}`} +
+ +

Command Bar Integration

+

+ Exporte sind auch ueber die Command Bar verfuegbar: +

+ +{`Cmd+K → "pdf" → "Als PDF exportieren" +Cmd+K → "zip" → "Als ZIP exportieren" +Cmd+K → "json" → "Als JSON exportieren"`} + + +

Automatisierte Exports

+

+ Fuer regelmaessige Backups oder CI/CD-Integration: +

+ +{`# Taeglicher Backup-Export um 02:00 Uhr +0 2 * * * curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=my-tenant" \\ + -H "Authorization: Bearer $API_KEY" \\ + -o "/backups/compliance-$(date +%Y%m%d).zip"`} + + + + ZIP-Exporte koennen bei umfangreichen States mehrere MB gross werden. + Die API hat ein Timeout von 60 Sekunden. Bei sehr grossen States + verwenden Sie den asynchronen Export-Endpoint (Enterprise). + + +

Fehlerbehandlung

+ +{`import { exportState } from '@breakpilot/compliance-sdk' + +try { + const blob = await exportState('pdf') + downloadExport(blob, 'report.pdf') +} catch (error) { + if (error.code === 'EMPTY_STATE') { + console.error('Keine Daten zum Exportieren vorhanden') + } else if (error.code === 'GENERATION_FAILED') { + console.error('PDF-Generierung fehlgeschlagen:', error.message) + } else if (error.code === 'TIMEOUT') { + console.error('Export-Timeout - versuchen Sie ZIP fuer grosse States') + } else { + console.error('Unbekannter Fehler:', error) + } +}`} + +
+ ) +} diff --git a/developer-portal/app/api/generate/page.tsx b/developer-portal/app/api/generate/page.tsx new file mode 100644 index 0000000..7c82bd5 --- /dev/null +++ b/developer-portal/app/api/generate/page.tsx @@ -0,0 +1,381 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function GenerateApiPage() { + return ( + +

Uebersicht

+

+ Die Generation API nutzt LLM-Technologie (Claude) zur automatischen Erstellung + von Compliance-Dokumenten basierend auf Ihrem SDK-State: +

+
    +
  • DSFA - Datenschutz-Folgenabschaetzung
  • +
  • TOM - Technische und Organisatorische Massnahmen
  • +
  • VVT - Verarbeitungsverzeichnis nach Art. 30 DSGVO
  • +
+ + + Die Generierung verwendet Claude 3.5 Sonnet fuer optimale Qualitaet + bei deutschen Rechtstexten. RAG-Context wird automatisch einbezogen. + + +

POST /generate/dsfa

+

Generiert eine Datenschutz-Folgenabschaetzung basierend auf dem aktuellen State.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/dsfa" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "tenantId": "your-tenant-id", + "useCaseId": "uc-ki-kundenanalyse", + "includeRisks": true, + "includeControls": true, + "language": "de" + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "dsfa": { + "id": "dsfa-2026-02-04-abc123", + "version": "1.0", + "status": "DRAFT", + "createdAt": "2026-02-04T12:00:00Z", + "useCase": { + "id": "uc-ki-kundenanalyse", + "name": "KI-gestuetzte Kundenanalyse", + "description": "Analyse von Kundenverhalten mittels ML..." + }, + "sections": { + "systematicDescription": { + "title": "1. Systematische Beschreibung", + "content": "Die geplante Verarbeitungstaetigkeit umfasst..." + }, + "necessityAssessment": { + "title": "2. Bewertung der Notwendigkeit", + "content": "Die Verarbeitung ist notwendig fuer..." + }, + "riskAssessment": { + "title": "3. Risikobewertung", + "risks": [ + { + "id": "risk-1", + "title": "Unbefugter Datenzugriff", + "severity": "HIGH", + "likelihood": 3, + "impact": 4, + "description": "...", + "mitigations": ["Verschluesselung", "Zugriffskontrolle"] + } + ] + }, + "mitigationMeasures": { + "title": "4. Abhilfemassnahmen", + "controls": [...] + }, + "stakeholderConsultation": { + "title": "5. Einbeziehung Betroffener", + "content": "..." + }, + "dpoOpinion": { + "title": "6. Stellungnahme des DSB", + "content": "Ausstehend - Freigabe erforderlich" + } + }, + "conclusion": { + "overallRisk": "MEDIUM", + "recommendation": "PROCEED_WITH_CONDITIONS", + "conditions": [ + "Implementierung der TOM-Empfehlungen", + "Regelmaessige Ueberpruefung" + ] + } + }, + "generationMeta": { + "model": "claude-3.5-sonnet", + "ragContextUsed": true, + "tokensUsed": 4250, + "durationMs": 8500 + } + } +}`} + + +

POST /generate/tom

+

Generiert technische und organisatorische Massnahmen.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/tom" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "tenantId": "your-tenant-id", + "categories": ["access_control", "encryption", "backup"], + "basedOnRisks": true + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "toms": [ + { + "id": "tom-1", + "category": "access_control", + "categoryLabel": "Zugangskontrolle", + "title": "Multi-Faktor-Authentifizierung", + "description": "Implementierung von MFA fuer alle Systemzugaenge", + "technicalMeasures": [ + "TOTP-basierte 2FA", + "Hardware Security Keys (FIDO2)" + ], + "organizationalMeasures": [ + "Schulung der Mitarbeiter", + "Dokumentation der Zugaenge" + ], + "article32Reference": "Art. 32 Abs. 1 lit. b DSGVO", + "priority": "HIGH", + "implementationStatus": "PLANNED" + }, + { + "id": "tom-2", + "category": "encryption", + "categoryLabel": "Verschluesselung", + "title": "Transportverschluesselung", + "description": "TLS 1.3 fuer alle Datenuebert\\\\ragungen", + "technicalMeasures": [ + "TLS 1.3 mit PFS", + "HSTS Header" + ], + "organizationalMeasures": [ + "Zertifikatsmanagement", + "Regelmaessige Audits" + ], + "article32Reference": "Art. 32 Abs. 1 lit. a DSGVO", + "priority": "CRITICAL", + "implementationStatus": "IMPLEMENTED" + } + ], + "summary": { + "totalMeasures": 20, + "byCategory": { + "access_control": 5, + "encryption": 4, + "backup": 3, + "monitoring": 4, + "incident_response": 4 + }, + "implementationProgress": { + "implemented": 12, + "in_progress": 5, + "planned": 3 + } + } + } +}`} + + +

POST /generate/vvt

+

Generiert ein Verarbeitungsverzeichnis nach Art. 30 DSGVO.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/vvt" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "tenantId": "your-tenant-id", + "organizationInfo": { + "name": "Beispiel GmbH", + "address": "Musterstrasse 1, 10115 Berlin", + "dpoContact": "datenschutz@beispiel.de" + }, + "includeRetentionPolicies": true + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "vvt": { + "id": "vvt-2026-02-04", + "version": "1.0", + "organization": { + "name": "Beispiel GmbH", + "address": "Musterstrasse 1, 10115 Berlin", + "dpoContact": "datenschutz@beispiel.de" + }, + "processingActivities": [ + { + "id": "pa-1", + "name": "Kundendatenverarbeitung", + "purpose": "Vertragserfuellung und Kundenservice", + "legalBasis": "Art. 6 Abs. 1 lit. b DSGVO", + "dataCategories": ["Kontaktdaten", "Vertragsdaten", "Zahlungsdaten"], + "dataSubjects": ["Kunden", "Interessenten"], + "recipients": ["Zahlungsdienstleister", "Versanddienstleister"], + "thirdCountryTransfers": { + "exists": false, + "countries": [], + "safeguards": null + }, + "retentionPeriod": "10 Jahre nach Vertragsende (HGB)", + "technicalMeasures": ["Verschluesselung", "Zugriffskontrolle"] + } + ], + "lastUpdated": "2026-02-04T12:00:00Z" + } + } +}`} + + +

SDK Integration

+ +{`import { getSDKBackendClient } from '@breakpilot/compliance-sdk' + +const client = getSDKBackendClient() + +// DSFA generieren +async function generateDSFA(useCaseId: string) { + const dsfa = await client.generateDSFA({ + useCaseId, + includeRisks: true, + includeControls: true, + }) + + console.log('DSFA generiert:', dsfa.id) + console.log('Gesamtrisiko:', dsfa.conclusion.overallRisk) + return dsfa +} + +// TOMs generieren +async function generateTOMs() { + const toms = await client.generateTOM({ + categories: ['access_control', 'encryption'], + basedOnRisks: true, + }) + + console.log(\`\${toms.length} TOMs generiert\`) + return toms +} + +// VVT generieren +async function generateVVT() { + const vvt = await client.generateVVT({ + organizationInfo: { + name: 'Beispiel GmbH', + address: 'Musterstrasse 1', + dpoContact: 'dpo@beispiel.de', + }, + }) + + console.log(\`VVT mit \${vvt.processingActivities.length} Verarbeitungen\`) + return vvt +}`} + + + + Die Dokumentengenerierung verbraucht LLM-Tokens. Durchschnittliche Kosten: + DSFA ~5.000 Tokens, TOMs ~3.000 Tokens, VVT ~4.000 Tokens. + Enterprise-Kunden haben unbegrenzte Generierungen. + +
+ ) +} diff --git a/developer-portal/app/api/page.tsx b/developer-portal/app/api/page.tsx new file mode 100644 index 0000000..2b660bf --- /dev/null +++ b/developer-portal/app/api/page.tsx @@ -0,0 +1,239 @@ +import Link from 'next/link' +import { DevPortalLayout, ApiEndpoint, InfoBox } from '@/components/DevPortalLayout' + +export default function ApiReferencePage() { + return ( + +

Base URL

+

+ Alle API-Endpunkte sind unter folgender Basis-URL erreichbar: +

+
+ https://api.breakpilot.io/sdk/v1 +
+

+ Für Self-Hosted-Installationen verwenden Sie Ihre eigene Domain. +

+ +

Authentifizierung

+

+ Alle API-Anfragen erfordern einen gültigen API Key im Header: +

+
+ Authorization: Bearer YOUR_API_KEY +
+ + + Die Tenant-ID wird aus dem API Key abgeleitet oder kann explizit + als Query-Parameter oder im Request-Body mitgegeben werden. + + +

API Endpoints

+ +

State Management

+

+ Verwalten Sie den SDK-State für Ihren Tenant. +

+ + + + + +

+ + → Vollständige State API Dokumentation + +

+ +

RAG Search

+

+ Durchsuchen Sie den Compliance-Korpus (DSGVO, AI Act, NIS2). +

+ + + + +

+ + → Vollständige RAG API Dokumentation + +

+ +

Document Generation

+

+ Generieren Sie Compliance-Dokumente automatisch. +

+ + + + + +

+ + → Vollständige Generation API Dokumentation + +

+ +

Export

+

+ Exportieren Sie den Compliance-Stand in verschiedenen Formaten. +

+ + + +

+ + → Vollständige Export API Dokumentation + +

+ +

Response Format

+

+ Alle Responses folgen einem einheitlichen Format: +

+ +

Erfolgreiche Response

+
+{`{ + "success": true, + "data": { ... }, + "meta": { + "version": 1, + "timestamp": "2026-02-04T12:00:00Z" + } +}`} +
+ +

Fehler Response

+
+{`{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Tenant ID is required", + "details": { ... } + } +}`} +
+ +

Error Codes

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HTTP StatusCodeBeschreibung
400VALIDATION_ERRORUngültige Request-Daten
401UNAUTHORIZEDFehlender oder ungültiger API Key
403FORBIDDENKeine Berechtigung für diese Ressource
404NOT_FOUNDRessource nicht gefunden
409CONFLICTVersions-Konflikt (Optimistic Locking)
429RATE_LIMITEDZu viele Anfragen
500INTERNAL_ERRORInterner Server-Fehler
+
+ +

Rate Limits

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
PlanRequests/MinuteRequests/Tag
Starter6010.000
Professional300100.000
EnterpriseUnbegrenztUnbegrenzt
+
+
+ ) +} diff --git a/developer-portal/app/api/rag/page.tsx b/developer-portal/app/api/rag/page.tsx new file mode 100644 index 0000000..2847e6c --- /dev/null +++ b/developer-portal/app/api/rag/page.tsx @@ -0,0 +1,248 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function RAGApiPage() { + return ( + +

Uebersicht

+

+ Die RAG (Retrieval-Augmented Generation) API ermoeglicht semantische Suche + im Compliance-Korpus. Der Korpus enthaelt: +

+
    +
  • DSGVO (Datenschutz-Grundverordnung)
  • +
  • AI Act (EU KI-Verordnung)
  • +
  • NIS2 (Netzwerk- und Informationssicherheit)
  • +
  • ePrivacy-Verordnung
  • +
  • Bundesdatenschutzgesetz (BDSG)
  • +
+ + + Die Suche verwendet BGE-M3 Embeddings fuer praezise semantische Aehnlichkeit. + Die Vektoren werden in Qdrant gespeichert. + + +

GET /rag/search

+

Durchsucht den Legal Corpus semantisch.

+ +

Query-Parameter

+ + +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/search?q=Einwilligung%20DSGVO&top_k=5" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "query": "Einwilligung DSGVO", + "results": [ + { + "id": "dsgvo-art-7", + "title": "Art. 7 DSGVO - Bedingungen fuer die Einwilligung", + "content": "Beruht die Verarbeitung auf einer Einwilligung, muss der Verantwortliche nachweisen koennen, dass die betroffene Person in die Verarbeitung ihrer personenbezogenen Daten eingewilligt hat...", + "corpus": "dsgvo", + "article": "Art. 7", + "score": 0.92, + "metadata": { + "chapter": "II", + "section": "Einwilligung", + "url": "https://dsgvo-gesetz.de/art-7-dsgvo/" + } + }, + { + "id": "dsgvo-art-6-1-a", + "title": "Art. 6 Abs. 1 lit. a DSGVO - Einwilligung als Rechtsgrundlage", + "content": "Die Verarbeitung ist nur rechtmaessig, wenn mindestens eine der nachstehenden Bedingungen erfuellt ist: a) Die betroffene Person hat ihre Einwilligung...", + "corpus": "dsgvo", + "article": "Art. 6", + "score": 0.88, + "metadata": { + "chapter": "II", + "section": "Rechtmaessigkeit", + "url": "https://dsgvo-gesetz.de/art-6-dsgvo/" + } + } + ], + "total_results": 2, + "search_time_ms": 45 + }, + "meta": { + "corpus_version": "2026-01", + "embedding_model": "bge-m3" + } +}`} + + +

GET /rag/status

+

Gibt Status-Informationen ueber das RAG-System zurueck.

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/status" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "status": "healthy", + "corpus": { + "dsgvo": { + "documents": 99, + "chunks": 1250, + "last_updated": "2026-01-15T00:00:00Z" + }, + "ai_act": { + "documents": 89, + "chunks": 980, + "last_updated": "2026-01-20T00:00:00Z" + }, + "nis2": { + "documents": 46, + "chunks": 520, + "last_updated": "2026-01-10T00:00:00Z" + } + }, + "embedding_service": { + "status": "online", + "model": "bge-m3", + "dimension": 1024 + }, + "vector_db": { + "type": "qdrant", + "collections": 3, + "total_vectors": 2750 + } + } +}`} + + +

SDK Integration

+

+ Verwenden Sie den SDK-Client fuer einfache RAG-Suche: +

+ +{`import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk' + +const client = getSDKBackendClient() + +// Pruefen ob die Query rechtliche Inhalte betrifft +if (isLegalQuery('Was ist eine Einwilligung?')) { + // RAG-Suche durchfuehren + const results = await client.search('Einwilligung DSGVO', 5) + + results.forEach(result => { + console.log(\`[\${result.corpus}] \${result.title}\`) + console.log(\`Score: \${result.score}\`) + console.log(\`URL: \${result.metadata.url}\`) + console.log('---') + }) +}`} + + +

Keyword-Erkennung

+

+ Die Funktion isLegalQuery erkennt automatisch rechtliche Anfragen: +

+ +{`import { isLegalQuery } from '@breakpilot/compliance-sdk' + +// Gibt true zurueck fuer: +isLegalQuery('DSGVO Art. 5') // true - Artikel-Referenz +isLegalQuery('Einwilligung') // true - DSGVO-Begriff +isLegalQuery('AI Act Hochrisiko') // true - AI Act Begriff +isLegalQuery('NIS2 Richtlinie') // true - NIS2 Referenz +isLegalQuery('personenbezogene Daten') // true - Datenschutz-Begriff + +// Gibt false zurueck fuer: +isLegalQuery('Wie ist das Wetter?') // false - Keine rechtliche Anfrage +isLegalQuery('Programmiere mir X') // false - Technische Anfrage`} + + +

Beispiel: Command Bar Integration

+ +{`import { useState } from 'react' +import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk' + +function CommandBarSearch({ query }: { query: string }) { + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (query.length > 3 && isLegalQuery(query)) { + setLoading(true) + const client = getSDKBackendClient() + + client.search(query, 3).then(data => { + setResults(data) + setLoading(false) + }) + } + }, [query]) + + if (!isLegalQuery(query)) return null + + return ( +
+ {loading ? ( +

Suche im Legal Corpus...

+ ) : ( + results.map(result => ( +
+

{result.title}

+

{result.content.slice(0, 200)}...

+ + Volltext lesen + +
+ )) + )} +
+ ) +}`} +
+ + + Die RAG-Suche ist auf 100 Anfragen/Minute (Professional) bzw. + unbegrenzt (Enterprise) limitiert. Implementieren Sie Client-Side + Debouncing fuer Echtzeit-Suche. + +
+ ) +} diff --git a/developer-portal/app/api/state/page.tsx b/developer-portal/app/api/state/page.tsx new file mode 100644 index 0000000..f7f3188 --- /dev/null +++ b/developer-portal/app/api/state/page.tsx @@ -0,0 +1,266 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function StateApiPage() { + return ( + +

Übersicht

+

+ Die State API ermöglicht das Speichern und Abrufen des kompletten SDK-States. + Der State enthält alle Compliance-Daten: Use Cases, Risiken, Controls, + Checkpoints und mehr. +

+ + + Der State wird mit optimistischem Locking gespeichert. Bei jedem Speichern + wird die Version erhöht. Bei Konflikten erhalten Sie einen 409-Fehler. + + +

GET /state/{'{tenantId}'}

+

Lädt den aktuellen SDK-State für einen Tenant.

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "version": "1.0.0", + "lastModified": "2026-02-04T12:00:00Z", + "tenantId": "your-tenant-id", + "userId": "user-123", + "subscription": "PROFESSIONAL", + "currentPhase": 1, + "currentStep": "use-case-workshop", + "completedSteps": ["use-case-workshop", "screening"], + "checkpoints": { + "CP-UC": { + "checkpointId": "CP-UC", + "passed": true, + "validatedAt": "2026-02-01T10:00:00Z", + "validatedBy": "user-123", + "errors": [], + "warnings": [] + } + }, + "useCases": [ + { + "id": "uc-1", + "name": "KI-Kundenanalyse", + "description": "...", + "category": "Marketing", + "stepsCompleted": 5, + "assessmentResult": { + "riskLevel": "HIGH", + "dsfaRequired": true, + "aiActClassification": "LIMITED" + } + } + ], + "risks": [...], + "controls": [...], + "dsfa": {...}, + "toms": [...], + "vvt": [...] + }, + "meta": { + "version": 5, + "etag": "W/\\"abc123\\"" + } +}`} + + +

Response (404 Not Found)

+ +{`{ + "success": false, + "error": { + "code": "NOT_FOUND", + "message": "No state found for tenant your-tenant-id" + } +}`} + + +

POST /state

+

Speichert den SDK-State. Unterstützt Versionierung und optimistisches Locking.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/state" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "If-Match: W/\\"abc123\\"" \\ + -d '{ + "tenantId": "your-tenant-id", + "userId": "user-123", + "state": { + "currentPhase": 1, + "currentStep": "risks", + "useCases": [...], + "risks": [...] + } + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "tenantId": "your-tenant-id", + "version": 6, + "updatedAt": "2026-02-04T12:05:00Z" + }, + "meta": { + "etag": "W/\\"def456\\"" + } +}`} + + +

Response (409 Conflict)

+ +{`{ + "success": false, + "error": { + "code": "CONFLICT", + "message": "Version conflict: expected 5, but current is 6", + "details": { + "expectedVersion": 5, + "currentVersion": 6 + } + } +}`} + + + + Bei einem 409-Fehler sollten Sie den State erneut laden, Ihre Änderungen + mergen und erneut speichern. + + +

DELETE /state/{'{tenantId}'}

+

Löscht den kompletten State für einen Tenant.

+ +

Request

+ +{`curl -X DELETE "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "tenantId": "your-tenant-id", + "deleted": true + } +}`} + + +

State-Struktur

+

Der SDKState enthält alle Compliance-Daten:

+ + +{`interface SDKState { + // Metadata + version: string + lastModified: Date + + // Tenant & User + tenantId: string + userId: string + subscription: 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE' + + // Progress + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + checkpoints: Record + + // Phase 1 Data + useCases: UseCaseAssessment[] + activeUseCase: string | null + screening: ScreeningResult | null + modules: ServiceModule[] + requirements: Requirement[] + controls: Control[] + evidence: Evidence[] + checklist: ChecklistItem[] + risks: Risk[] + + // Phase 2 Data + aiActClassification: AIActResult | null + obligations: Obligation[] + dsfa: DSFA | null + toms: TOM[] + retentionPolicies: RetentionPolicy[] + vvt: ProcessingActivity[] + documents: LegalDocument[] + cookieBanner: CookieBannerConfig | null + consents: ConsentRecord[] + dsrConfig: DSRConfig | null + escalationWorkflows: EscalationWorkflow[] + + // UI State + preferences: UserPreferences +}`} + + +

Beispiel: SDK Integration

+ +{`import { getSDKApiClient } from '@breakpilot/compliance-sdk' + +const client = getSDKApiClient('your-tenant-id') + +// State laden +const state = await client.getState() +console.log('Current step:', state.currentStep) +console.log('Use cases:', state.useCases.length) + +// State speichern +await client.saveState({ + ...state, + currentStep: 'risks', + risks: [...state.risks, newRisk], +})`} + +
+ ) +} diff --git a/developer-portal/app/changelog/page.tsx b/developer-portal/app/changelog/page.tsx new file mode 100644 index 0000000..7a1bb7e --- /dev/null +++ b/developer-portal/app/changelog/page.tsx @@ -0,0 +1,164 @@ +import { DevPortalLayout, InfoBox } from '@/components/DevPortalLayout' + +export default function ChangelogPage() { + return ( + +

Versionierung

+

+ Das SDK folgt Semantic Versioning (SemVer): + MAJOR.MINOR.PATCH +

+
    +
  • MAJOR: Breaking Changes
  • +
  • MINOR: Neue Features, abwaertskompatibel
  • +
  • PATCH: Bugfixes
  • +
+ + {/* Version 1.2.0 */} +
+
+ + v1.2.0 + + 2026-02-04 + Latest +
+ +

Neue Features

+
    +
  • Demo-Daten Seeding ueber API (nicht mehr hardcodiert)
  • +
  • Playwright E2E Tests fuer alle 19 SDK-Schritte
  • +
  • Command Bar RAG-Integration mit Live-Suche
  • +
  • Developer Portal mit API-Dokumentation
  • +
  • TOM-Katalog mit 20 vorkonfigurierten Massnahmen
  • +
  • VVT-Templates fuer gaengige Verarbeitungstaetigkeiten
  • +
+ +

Verbesserungen

+
    +
  • Performance-Optimierung beim State-Loading
  • +
  • Bessere TypeScript-Typen fuer alle Exports
  • +
  • Verbesserte Fehlerbehandlung bei API-Calls
  • +
+ +

Bugfixes

+
    +
  • Fix: Checkpoint-Validierung bei leeren Arrays
  • +
  • Fix: Multi-Tab-Sync bei Safari
  • +
  • Fix: Export-Dateiname mit Sonderzeichen
  • +
+
+ + {/* Version 1.1.0 */} +
+
+ + v1.1.0 + + 2026-01-20 +
+ +

Neue Features

+
    +
  • Backend-Sync mit PostgreSQL-Persistierung
  • +
  • SDK Backend (Go) mit RAG + LLM-Integration
  • +
  • Automatische DSFA-Generierung via Claude API
  • +
  • Export nach PDF, ZIP, JSON
  • +
+ +

Verbesserungen

+
    +
  • Offline-Support mit localStorage Fallback
  • +
  • Optimistic Locking fuer Konfliktbehandlung
  • +
  • BroadcastChannel fuer Multi-Tab-Sync
  • +
+
+ + {/* Version 1.0.0 */} +
+
+ + v1.0.0 + + 2026-01-01 +
+ +

Initial Release

+
    +
  • SDKProvider mit React Context
  • +
  • useSDK Hook mit vollstaendigem State-Zugriff
  • +
  • 19-Schritte Compliance-Workflow (Phase 1 + 2)
  • +
  • Checkpoint-Validierung
  • +
  • Risk Matrix mit Score-Berechnung
  • +
  • TypeScript-Support mit allen Types
  • +
  • Utility Functions fuer Navigation und Berechnung
  • +
+
+ + {/* Breaking Changes Notice */} + +

+ Bei Major-Version-Updates (z.B. 1.x → 2.x) koennen Breaking Changes auftreten. + Pruefen Sie die Migration Guides vor dem Upgrade. +

+

+ Das SDK speichert die State-Version im localStorage. Bei inkompatiblen + Aenderungen wird automatisch eine Migration durchgefuehrt. +

+
+ +

Geplante Features

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureVersionStatus
Multi-Tenant-Supportv1.3.0In Entwicklung
Workflow-Customizationv1.3.0Geplant
Audit-Trail Exportv1.4.0Geplant
White-Label Brandingv2.0.0Roadmap
+
+ +

Feedback & Issues

+

+ Fuer Bug-Reports und Feature-Requests nutzen Sie bitte: +

+
    +
  • + GitHub Issues:{' '} + github.com/breakpilot/compliance-sdk/issues +
  • +
  • + Support:{' '} + support@breakpilot.io +
  • +
+
+ ) +} diff --git a/developer-portal/app/getting-started/page.tsx b/developer-portal/app/getting-started/page.tsx new file mode 100644 index 0000000..2be74c7 --- /dev/null +++ b/developer-portal/app/getting-started/page.tsx @@ -0,0 +1,203 @@ +import Link from 'next/link' +import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/DevPortalLayout' + +export default function GettingStartedPage() { + return ( + +

1. Installation

+

+ Installieren Sie das SDK über Ihren bevorzugten Paketmanager: +

+ +{`npm install @breakpilot/compliance-sdk +# oder +yarn add @breakpilot/compliance-sdk +# oder +pnpm add @breakpilot/compliance-sdk`} + + +

2. API Key erhalten

+

+ Nach dem Abo-Abschluss erhalten Sie Ihren API Key im{' '} + + Einstellungsbereich + . +

+ + + Speichern Sie den API Key niemals im Frontend-Code. Verwenden Sie + Umgebungsvariablen auf dem Server. + + +

3. Provider einrichten

+

+ Wrappen Sie Ihre App mit dem SDKProvider: +

+ +{`import { SDKProvider } from '@breakpilot/compliance-sdk' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +}`} + + +

Provider Props

+ + +

4. SDK verwenden

+

+ Nutzen Sie den useSDK Hook in Ihren Komponenten: +

+ +{`'use client' + +import { useSDK } from '@breakpilot/compliance-sdk' + +export function ComplianceDashboard() { + const { + state, + completionPercentage, + goToStep, + currentStep, + } = useSDK() + + return ( +
+

+ Compliance Fortschritt: {completionPercentage}% +

+ +
+

Aktueller Schritt: {currentStep?.name}

+

Phase: {state.currentPhase}

+

Use Cases: {state.useCases.length}

+
+ +
+ + +
+
+ ) +}`} +
+ +

5. Erste Schritte im Workflow

+

+ Das SDK führt Sie durch einen 19-Schritte-Workflow in 2 Phasen: +

+ +
+
+
+

Phase 1: Assessment

+
    +
  1. Use Case Workshop
  2. +
  3. System Screening
  4. +
  5. Compliance Modules
  6. +
  7. Requirements
  8. +
  9. Controls
  10. +
  11. Evidence
  12. +
  13. Audit Checklist
  14. +
  15. Risk Matrix
  16. +
+
+
+

Phase 2: Dokumentation

+
    +
  1. AI Act Klassifizierung
  2. +
  3. Pflichtenübersicht
  4. +
  5. DSFA
  6. +
  7. TOMs
  8. +
  9. Löschfristen
  10. +
  11. VVT
  12. +
  13. Rechtliche Vorlagen
  14. +
  15. Cookie Banner
  16. +
  17. Einwilligungen
  18. +
  19. DSR Portal
  20. +
  21. Escalations
  22. +
+
+
+
+ +

6. Nächste Schritte

+
    +
  • + + SDK Konfiguration + + {' '}- Alle Konfigurationsoptionen +
  • +
  • + + State API + + {' '}- Verstehen Sie das State Management +
  • +
  • + + Phase 1 Guide + + {' '}- Kompletter Workflow für das Assessment +
  • +
+
+ ) +} diff --git a/developer-portal/app/globals.css b/developer-portal/app/globals.css new file mode 100644 index 0000000..0857e21 --- /dev/null +++ b/developer-portal/app/globals.css @@ -0,0 +1,35 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Smooth transitions */ +* { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Focus styles */ +*:focus-visible { + outline: 2px solid #0ea5e9; + outline-offset: 2px; +} diff --git a/developer-portal/app/guides/page.tsx b/developer-portal/app/guides/page.tsx new file mode 100644 index 0000000..be27d41 --- /dev/null +++ b/developer-portal/app/guides/page.tsx @@ -0,0 +1,227 @@ +import Link from 'next/link' +import { DevPortalLayout, InfoBox } from '@/components/DevPortalLayout' + +export default function GuidesPage() { + return ( + +

Workflow-Guides

+

+ Das AI Compliance SDK fuehrt durch einen strukturierten 19-Schritte-Workflow + in zwei Phasen. Diese Guides erklaeren jeden Schritt im Detail. +

+ +
+ +
+
+ 1 +
+
+

Phase 1: Assessment

+

8 Schritte

+
+
+

+ Use Case Workshop, System Screening, Module-Auswahl, Requirements, + Controls, Evidence, Checkliste, Risk Matrix. +

+ + + +
+
+ 2 +
+
+

Phase 2: Dokumentation

+

11 Schritte

+
+
+

+ AI Act Klassifizierung, Pflichten, DSFA, TOMs, Loeschfristen, + VVT, Rechtliche Vorlagen, Cookie Banner, DSR Portal. +

+ +
+ +

Workflow-Uebersicht

+
+
+

Phase 1: Assessment (8 Schritte)

+
    +
  1. + 01 +

    Use Case Workshop

    +
  2. +
  3. + 02 +

    System Screening

    +
  4. +
  5. + 03 +

    Compliance Modules

    +
  6. +
  7. + 04 +

    Requirements

    +
  8. +
  9. + 05 +

    Controls

    +
  10. +
  11. + 06 +

    Evidence

    +
  12. +
  13. + 07 +

    Audit Checklist

    +
  14. +
  15. + 08 +

    Risk Matrix

    +
  16. +
+
+ +
+

Phase 2: Dokumentation (11 Schritte)

+
    +
  1. + 09 +

    AI Act Klassifizierung

    +
  2. +
  3. + 10 +

    Pflichtenuebersicht

    +
  4. +
  5. + 11 +

    DSFA

    +
  6. +
  7. + 12 +

    TOMs

    +
  8. +
  9. + 13 +

    Loeschfristen

    +
  10. +
  11. + 14 +

    VVT

    +
  12. +
  13. + 15 +

    Rechtliche Vorlagen

    +
  14. +
  15. + 16 +

    Cookie Banner

    +
  16. +
  17. + 17 +

    Einwilligungen

    +
  18. +
  19. + 18 +

    DSR Portal

    +
  20. +
  21. + 19 +

    Escalations

    +
  22. +
+
+
+ +

Checkpoints

+

+ Das SDK validiert den Fortschritt an definierten Checkpoints: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CheckpointNach SchrittValidierung
CP-UCUse Case WorkshopMind. 1 Use Case angelegt
CP-SCREENSystem ScreeningScreening abgeschlossen
CP-CTRLControlsAlle Requirements haben Controls
CP-RISKRisk MatrixAlle Risiken bewertet
CP-DSFADSFADSFA generiert (falls erforderlich)
CP-TOMTOMsMind. 10 TOMs definiert
CP-VVTVVTVVT vollstaendig
+
+ + + Nicht bestandene Checkpoints blockieren den Fortschritt zu spaetere Schritte. + Verwenden Sie validateCheckpoint() um den Status zu pruefen. + + +

Best Practices

+
    +
  • + Speichern Sie regelmaessig: Der State wird automatisch + im localStorage gespeichert, aber aktivieren Sie Backend-Sync fuer + persistente Speicherung. +
  • +
  • + Nutzen Sie die Command Bar: Cmd+K oeffnet schnelle + Navigation, Export und RAG-Suche. +
  • +
  • + Arbeiten Sie Use-Case-zentriert: Bearbeiten Sie + einen Use Case vollstaendig, bevor Sie zum naechsten wechseln. +
  • +
  • + Validieren Sie Checkpoints: Pruefen Sie vor dem + Phasenwechsel, ob alle Checkpoints bestanden sind. +
  • +
+
+ ) +} diff --git a/developer-portal/app/guides/phase1/page.tsx b/developer-portal/app/guides/phase1/page.tsx new file mode 100644 index 0000000..653b9d8 --- /dev/null +++ b/developer-portal/app/guides/phase1/page.tsx @@ -0,0 +1,391 @@ +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function Phase1GuidePage() { + return ( + +

Uebersicht Phase 1

+

+ Phase 1 umfasst die Erfassung und Bewertung Ihrer KI-Anwendungsfaelle. + Am Ende haben Sie eine vollstaendige Risikoanalyse und wissen, welche + Compliance-Dokumente Sie benoetigen. +

+ +
+

Phase 1 Schritte

+
    +
  1. Use Case Workshop
  2. +
  3. System Screening
  4. +
  5. Compliance Modules
  6. +
  7. Requirements
  8. +
  9. Controls
  10. +
  11. Evidence
  12. +
  13. Audit Checklist
  14. +
  15. Risk Matrix
  16. +
+
+ +

Schritt 1: Use Case Workshop

+

+ Erfassen Sie alle KI-Anwendungsfaelle in Ihrem Unternehmen. +

+ +

Code-Beispiel

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function UseCaseForm() { + const { updateUseCase, state } = useSDK() + + const handleCreateUseCase = async () => { + await updateUseCase({ + id: \`uc-\${Date.now()}\`, + name: 'KI-gestuetzte Kundenanalyse', + description: 'Analyse von Kundenverhalten mittels ML', + category: 'Marketing', + department: 'Marketing & Sales', + dataTypes: ['Kundendaten', 'Verhaltensdaten', 'Transaktionen'], + aiCapabilities: ['Profiling', 'Vorhersage'], + stepsCompleted: 0, + }) + } + + return ( +
+

Use Cases: {state.useCases.length}

+ + + {state.useCases.map(uc => ( +
+

{uc.name}

+

{uc.description}

+
+ ))} +
+ ) +}`} +
+ + + Nach dem Use Case Workshop muss mindestens ein Use Case angelegt sein, + um zum naechsten Schritt zu gelangen. + + +

Schritt 2: System Screening

+

+ Das Screening bewertet jeden Use Case hinsichtlich Datenschutz und AI Act. +

+ +

Code-Beispiel

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function ScreeningView() { + const { state, dispatch } = useSDK() + + const completeScreening = (useCaseId: string, result: ScreeningResult) => { + dispatch({ + type: 'UPDATE_USE_CASE', + payload: { + id: useCaseId, + screeningResult: result, + // Ergebnis bestimmt weitere Pflichten + assessmentResult: { + riskLevel: result.aiActRisk, + dsfaRequired: result.dsfaRequired, + aiActClassification: result.aiActClassification, + }, + }, + }) + } + + // Screening-Fragen beantworten + const screeningQuestions = [ + 'Werden personenbezogene Daten verarbeitet?', + 'Erfolgt automatisierte Entscheidungsfindung?', + 'Werden besondere Datenkategorien verarbeitet?', + 'Erfolgt Profiling?', + 'Werden Daten in Drittlaender uebermittelt?', + ] + + return ( +
+ {screeningQuestions.map((question, i) => ( + + ))} +
+ ) +}`} +
+ +

Schritt 3: Compliance Modules

+

+ Basierend auf dem Screening werden relevante Compliance-Module aktiviert. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModulAktiviert wenn
DSGVO BasisImmer (personenbezogene Daten)
DSFAHohes Risiko, Profiling, Art. 9 Daten
AI ActKI-basierte Entscheidungen
NIS2Kritische Infrastruktur
+
+ +

Schritt 4: Requirements

+

+ Fuer jedes aktivierte Modul werden spezifische Anforderungen generiert. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function RequirementsView() { + const { state } = useSDK() + + // Requirements nach Modul gruppieren + const byModule = state.requirements.reduce((acc, req) => { + const module = req.module || 'general' + if (!acc[module]) acc[module] = [] + acc[module].push(req) + return acc + }, {}) + + return ( +
+ {Object.entries(byModule).map(([module, reqs]) => ( +
+

{module}

+
    + {reqs.map(req => ( +
  • + {req.title} +

    {req.description}

    + Status: {req.status} +
  • + ))} +
+
+ ))} +
+ ) +}`} +
+ +

Schritt 5: Controls

+

+ Definieren Sie Kontrollen fuer jede Anforderung. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function ControlsView() { + const { updateControl, state } = useSDK() + + const addControl = (requirementId: string) => { + updateControl({ + id: \`ctrl-\${Date.now()}\`, + requirementId, + title: 'Zugriffskontrolle implementieren', + description: 'Role-based access control fuer alle Datenzugaenge', + type: 'TECHNICAL', + status: 'PLANNED', + implementationDate: null, + owner: 'IT-Abteilung', + }) + } + + return ( +
+

Controls: {state.controls.length}

+ + {state.requirements.map(req => ( +
+

{req.title}

+

Controls: {state.controls.filter(c => c.requirementId === req.id).length}

+ +
+ ))} +
+ ) +}`} +
+ + + Jede Requirement muss mindestens ein Control haben, bevor Sie + zur Evidence-Phase uebergehen koennen. + + +

Schritt 6: Evidence

+

+ Dokumentieren Sie Nachweise fuer implementierte Controls. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function EvidenceUpload({ controlId }: { controlId: string }) { + const { dispatch } = useSDK() + + const addEvidence = (file: File) => { + dispatch({ + type: 'ADD_EVIDENCE', + payload: { + id: \`ev-\${Date.now()}\`, + controlId, + title: file.name, + type: 'DOCUMENT', + uploadedAt: new Date().toISOString(), + fileType: file.type, + // In Produktion: Upload zu Storage + }, + }) + } + + return ( + e.target.files?.[0] && addEvidence(e.target.files[0])} + /> + ) +}`} + + +

Schritt 7: Audit Checklist

+

+ Die Checkliste fasst alle Compliance-Punkte zusammen. +

+ +

Schritt 8: Risk Matrix

+

+ Bewerten Sie alle identifizierten Risiken nach Likelihood und Impact. +

+ + +{`import { useSDK, calculateRiskScore, getRiskSeverityFromScore } from '@breakpilot/compliance-sdk' + +function RiskMatrix() { + const { addRisk, state } = useSDK() + + const createRisk = () => { + const likelihood = 3 // 1-5 + const impact = 4 // 1-5 + const score = calculateRiskScore(likelihood, impact) // 12 + const severity = getRiskSeverityFromScore(score) // 'HIGH' + + addRisk({ + id: \`risk-\${Date.now()}\`, + title: 'Unbefugter Datenzugriff', + description: 'Risiko durch unzureichende Zugriffskontrolle', + likelihood, + impact, + inherentScore: score, + severity, + category: 'Security', + mitigations: [], + residualScore: null, + }) + } + + return ( +
+

Risiken: {state.risks.length}

+ + {/* 5x5 Matrix Visualisierung */} +
+ {[5,4,3,2,1].map(likelihood => ( + [1,2,3,4,5].map(impact => { + const score = likelihood * impact + const risksHere = state.risks.filter( + r => r.likelihood === likelihood && r.impact === impact + ) + return ( +
= 15 ? 'bg-red-500' : score >= 8 ? 'bg-yellow-500' : 'bg-green-500'}\`} + > + {risksHere.length > 0 && ( + {risksHere.length} + )} +
+ ) + }) + ))} +
+ + +
+ ) +}`} +
+ + + Nach erfolgreicher Bewertung aller Risiken koennen Sie zu Phase 2 + uebergehen. Der Checkpoint CP-RISK validiert, dass alle Risiken + eine Severity-Bewertung haben. + + +

Navigation nach Phase 2

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function PhaseTransition() { + const { validateCheckpoint, goToStep, phase1Completion } = useSDK() + + const handleContinueToPhase2 = async () => { + // Alle Phase-1-Checkpoints pruefen + const cpRisk = await validateCheckpoint('CP-RISK') + + if (cpRisk.passed) { + goToStep('ai-act-classification') // Erster Schritt Phase 2 + } else { + console.error('Checkpoint nicht bestanden:', cpRisk.errors) + } + } + + return ( +
+

Phase 1 Fortschritt: {phase1Completion}%

+ + {phase1Completion === 100 && ( + + )} +
+ ) +}`} +
+
+ ) +} diff --git a/developer-portal/app/guides/phase2/page.tsx b/developer-portal/app/guides/phase2/page.tsx new file mode 100644 index 0000000..8f3c3fd --- /dev/null +++ b/developer-portal/app/guides/phase2/page.tsx @@ -0,0 +1,377 @@ +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function Phase2GuidePage() { + return ( + +

Uebersicht Phase 2

+

+ Phase 2 generiert alle erforderlichen Compliance-Dokumente basierend + auf dem Assessment aus Phase 1. Die Dokumente koennen exportiert und + fuer Audits verwendet werden. +

+ +
+

Phase 2 Schritte

+
    +
  1. AI Act Klassifizierung
  2. +
  3. Pflichtenuebersicht
  4. +
  5. DSFA (Datenschutz-Folgenabschaetzung)
  6. +
  7. TOMs (Technische/Organisatorische Massnahmen)
  8. +
  9. Loeschfristen
  10. +
  11. VVT (Verarbeitungsverzeichnis)
  12. +
  13. Rechtliche Vorlagen
  14. +
  15. Cookie Banner
  16. +
  17. Einwilligungen
  18. +
  19. DSR Portal
  20. +
  21. Escalations
  22. +
+
+ +

Schritt 9: AI Act Klassifizierung

+

+ Klassifizieren Sie jeden Use Case nach dem EU AI Act Risikosystem. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RisikostufeBeschreibungPflichten
VerbotenSocial Scoring, Manipulative KINicht zulaessig
HochrisikoBiometrie, Medizin, kritische InfrastrukturUmfangreiche Dokumentation, Konformitaetsbewertung
BegrenztChatbots, EmpfehlungssystemeTransparenzpflichten
MinimalSpam-Filter, SpieleFreiwillige Verhaltenskodizes
+
+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' +import type { AIActRiskCategory } from '@breakpilot/compliance-sdk' + +function AIActClassification() { + const { state, dispatch } = useSDK() + + const classifyUseCase = (useCaseId: string, classification: AIActRiskCategory) => { + dispatch({ + type: 'UPDATE_USE_CASE', + payload: { + id: useCaseId, + assessmentResult: { + ...state.useCases.find(uc => uc.id === useCaseId)?.assessmentResult, + aiActClassification: classification, + }, + }, + }) + + // Wenn Hochrisiko, zusaetzliche Pflichten aktivieren + if (classification === 'HIGH_RISK') { + dispatch({ + type: 'SET_AI_ACT_RESULT', + payload: { + classification, + conformityRequired: true, + documentationRequired: true, + humanOversightRequired: true, + }, + }) + } + } + + return ( +
+ {state.useCases.map(uc => ( +
+

{uc.name}

+ +
+ ))} +
+ ) +}`} +
+ +

Schritt 10: Pflichtenuebersicht

+

+ Basierend auf der Klassifizierung werden alle anwendbaren Pflichten angezeigt. +

+ +

Schritt 11: DSFA

+

+ Die Datenschutz-Folgenabschaetzung wird automatisch generiert. +

+ + +{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk' + +function DSFAGeneration() { + const { state, dispatch } = useSDK() + const [generating, setGenerating] = useState(false) + + const generateDSFA = async () => { + setGenerating(true) + + const client = getSDKBackendClient() + const dsfa = await client.generateDSFA({ + useCases: state.useCases, + risks: state.risks, + controls: state.controls, + }) + + dispatch({ + type: 'SET_DSFA', + payload: dsfa, + }) + + setGenerating(false) + } + + // DSFA nur anzeigen wenn erforderlich + const dsfaRequired = state.useCases.some( + uc => uc.assessmentResult?.dsfaRequired + ) + + if (!dsfaRequired) { + return

Keine DSFA erforderlich fuer die aktuellen Use Cases.

+ } + + return ( +
+ {state.dsfa ? ( +
+

DSFA generiert

+

Status: {state.dsfa.status}

+

Gesamtrisiko: {state.dsfa.conclusion?.overallRisk}

+ + {/* DSFA-Sektionen anzeigen */} + {Object.entries(state.dsfa.sections || {}).map(([key, section]) => ( +
+

{section.title}

+

{section.content}

+
+ ))} +
+ ) : ( + + )} +
+ ) +}`} +
+ + + Wenn eine DSFA erforderlich ist (basierend auf Screening), muss diese + generiert werden, bevor Sie fortfahren koennen. + + +

Schritt 12: TOMs

+

+ Technische und Organisatorische Massnahmen nach Art. 32 DSGVO. +

+ + +{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk' + +function TOMsView() { + const { state, dispatch } = useSDK() + + const generateTOMs = async () => { + const client = getSDKBackendClient() + const toms = await client.generateTOM({ + risks: state.risks, + controls: state.controls, + }) + + dispatch({ + type: 'SET_TOMS', + payload: toms, + }) + } + + const tomCategories = [ + { id: 'access_control', label: 'Zugangskontrolle' }, + { id: 'access_rights', label: 'Zugriffskontrolle' }, + { id: 'transfer_control', label: 'Weitergabekontrolle' }, + { id: 'input_control', label: 'Eingabekontrolle' }, + { id: 'availability', label: 'Verfuegbarkeitskontrolle' }, + { id: 'separation', label: 'Trennungsgebot' }, + ] + + return ( +
+

TOMs: {state.toms.length}

+ + {tomCategories.map(cat => { + const tomsInCategory = state.toms.filter(t => t.category === cat.id) + return ( +
+

{cat.label} ({tomsInCategory.length})

+
    + {tomsInCategory.map(tom => ( +
  • + {tom.title} +

    {tom.description}

    + Status: {tom.implementationStatus} +
  • + ))} +
+
+ ) + })} + + +
+ ) +}`} +
+ +

Schritt 13: Loeschfristen

+

+ Definieren Sie Aufbewahrungsfristen fuer verschiedene Datenkategorien. +

+ +

Schritt 14: VVT

+

+ Das Verarbeitungsverzeichnis nach Art. 30 DSGVO. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function VVTView() { + const { state, dispatch } = useSDK() + + const addProcessingActivity = () => { + dispatch({ + type: 'ADD_PROCESSING_ACTIVITY', + payload: { + id: \`pa-\${Date.now()}\`, + name: 'Kundendatenverarbeitung', + purpose: 'Vertragserfuellung', + legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO', + dataCategories: ['Kontaktdaten', 'Vertragsdaten'], + dataSubjects: ['Kunden'], + recipients: [], + retentionPeriod: '10 Jahre', + technicalMeasures: ['Verschluesselung', 'Zugriffskontrolle'], + }, + }) + } + + return ( +
+

Verarbeitungstaetigkeiten: {state.vvt.length}

+ + {state.vvt.map(activity => ( +
+

{activity.name}

+

Zweck: {activity.purpose}

+

Rechtsgrundlage: {activity.legalBasis}

+

Datenkategorien: {activity.dataCategories.join(', ')}

+

Betroffene: {activity.dataSubjects.join(', ')}

+

Loeschfrist: {activity.retentionPeriod}

+
+ ))} + + +
+ ) +}`} +
+ +

Schritt 15-19: Weitere Dokumentation

+

+ Die verbleibenden Schritte umfassen: +

+
    +
  • Rechtliche Vorlagen: AGB, Datenschutzerklaerung, etc.
  • +
  • Cookie Banner: Konfiguration fuer Cookie-Consent
  • +
  • Einwilligungen: Consent-Management fuer Betroffene
  • +
  • DSR Portal: Data Subject Request Handling
  • +
  • Escalations: Eskalationspfade fuer Datenschutzvorfaelle
  • +
+ +

Export der Dokumentation

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function ExportAll() { + const { exportState, completionPercentage } = useSDK() + + const handleExport = async (format: 'pdf' | 'zip' | 'json') => { + const blob = await exportState(format) + + // Download ausloesen + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = \`compliance-export.\${format === 'json' ? 'json' : format}\` + a.click() + URL.revokeObjectURL(url) + } + + return ( +
+

Compliance Fortschritt: {completionPercentage}%

+ +
+ + + +
+
+ ) +}`} +
+ + + Nach Abschluss aller 19 Schritte haben Sie eine vollstaendige + Compliance-Dokumentation, die Sie fuer Audits und regulatorische + Anforderungen verwenden koennen. + +
+ ) +} diff --git a/developer-portal/app/layout.tsx b/developer-portal/app/layout.tsx new file mode 100644 index 0000000..57f1735 --- /dev/null +++ b/developer-portal/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'BreakPilot Developer Portal', + description: 'SDK-Dokumentation und API-Referenz fuer BreakPilot AI Compliance SDK', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/developer-portal/app/page.tsx b/developer-portal/app/page.tsx new file mode 100644 index 0000000..0e98f81 --- /dev/null +++ b/developer-portal/app/page.tsx @@ -0,0 +1,188 @@ +import Link from 'next/link' +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' +import { Zap, Code, Terminal, Book, ArrowRight } from 'lucide-react' + +export default function DevelopersPage() { + return ( + + {/* Quick Links */} +
+ +
+
+ +
+

Quick Start

+
+

+ Starten Sie in 5 Minuten mit dem AI Compliance SDK +

+ + Jetzt starten + + + + +
+
+ +
+

API Reference

+
+

+ Vollständige API-Dokumentation aller Endpoints +

+ + API erkunden + + + + +
+
+ +
+

SDK Documentation

+
+

+ TypeScript SDK für React und Next.js +

+ + Dokumentation lesen + + + + +
+
+ +
+

Guides

+
+

+ Schritt-für-Schritt-Anleitungen und Best Practices +

+ + Guides ansehen + + +
+ + {/* Installation */} +

Installation

+ +{`npm install @breakpilot/compliance-sdk +# oder +yarn add @breakpilot/compliance-sdk +# oder +pnpm add @breakpilot/compliance-sdk`} + + + {/* Quick Example */} +

Schnellstart-Beispiel

+ +{`import { SDKProvider, useSDK } from '@breakpilot/compliance-sdk' + +function App() { + return ( + + + + ) +} + +function ComplianceDashboard() { + const { state, goToStep, completionPercentage } = useSDK() + + return ( +
+

Compliance Status: {completionPercentage}%

+

Aktueller Schritt: {state.currentStep}

+ +
+ ) +}`} +
+ + +
    +
  • Node.js 18 oder höher
  • +
  • React 18 oder höher
  • +
  • Breakpilot API Key (erhältlich nach Abo-Abschluss)
  • +
+
+ + {/* Features */} +

Hauptfunktionen

+
+
+

19-Schritt-Workflow

+

+ Geführter Compliance-Prozess von Use Case bis DSR-Portal +

+
+
+

RAG-basierte Suche

+

+ Durchsuchen Sie DSGVO, AI Act, NIS2 mit semantischer Suche +

+
+
+

Dokumentengenerierung

+

+ Automatische Erstellung von DSFA, TOMs, VVT +

+
+
+

Export

+

+ PDF, JSON, ZIP-Export für Audits und Dokumentation +

+
+
+ + {/* Next Steps */} +

Nächste Schritte

+
    +
  1. + + Quick Start Guide + + {' '}- Erste Integration in 5 Minuten +
  2. +
  3. + + State API + + {' '}- Verstehen Sie das State Management +
  4. +
  5. + + Phase 1 Workflow + + {' '}- Durchlaufen Sie den Compliance-Prozess +
  6. +
+
+ ) +} diff --git a/developer-portal/app/sdk/configuration/page.tsx b/developer-portal/app/sdk/configuration/page.tsx new file mode 100644 index 0000000..47d392d --- /dev/null +++ b/developer-portal/app/sdk/configuration/page.tsx @@ -0,0 +1,256 @@ +import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/DevPortalLayout' + +export default function SDKConfigurationPage() { + return ( + +

SDKProvider Props

+

+ Der SDKProvider akzeptiert folgende Konfigurationsoptionen: +

+ void', + required: false, + description: 'Callback fuer Fehlerbehandlung', + }, + { + name: 'onStateChange', + type: '(state: SDKState) => void', + required: false, + description: 'Callback bei State-Aenderungen', + }, + ]} + /> + +

Vollstaendiges Beispiel

+ +{`'use client' + +import { SDKProvider } from '@breakpilot/compliance-sdk' +import { useRouter } from 'next/navigation' + +export default function SDKLayout({ children }: { children: React.ReactNode }) { + const router = useRouter() + + return ( + { + console.error('SDK Error:', error) + // Optional: Sentry oder anderes Error-Tracking + }} + onStateChange={(state) => { + console.log('State changed:', state.currentStep) + // Optional: Analytics-Events + }} + > + {children} + + ) +}`} + + +

Synchronisations-Strategien

+ +

1. Nur localStorage (Offline-Only)

+ +{` + {children} +`} + +

+ Ideal fuer: Lokale Entwicklung, Demos, Privacy-fokussierte Installationen. + Daten werden nur im Browser gespeichert. +

+ +

2. Backend-Sync mit Fallback

+ +{` + {children} +`} + +

+ Empfohlen fuer: Produktionsumgebungen. Daten werden mit dem Backend + synchronisiert, localStorage dient als Fallback bei Netzwerkproblemen. +

+ +

3. Nur Backend (kein lokaler Cache)

+ +{` + {children} +`} + +

+ Ideal fuer: Strenge Compliance-Anforderungen, Multi-User-Szenarien. + Daten werden nur im Backend gespeichert. +

+ + + Im Backend-Only Modus ist eine aktive Internetverbindung erforderlich. + Bei Netzwerkproblemen koennen Daten verloren gehen. + + +

API URL Konfiguration

+ +

Cloud-Version (Standard)

+

Keine zusaetzliche Konfiguration erforderlich:

+ +{` + {/* Nutzt automatisch https://api.breakpilot.io/sdk/v1 */} +`} + + +

Self-Hosted

+ +{` + {children} +`} + + +

Lokale Entwicklung

+ +{` + {children} +`} + + +

Feature Flags

+

+ Das SDK unterstuetzt Feature Flags ueber Subscription-Levels: +

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function MyComponent() { + const { state } = useSDK() + + // Subscription-basierte Features + const isEnterprise = state.subscription === 'ENTERPRISE' + const isProfessional = ['PROFESSIONAL', 'ENTERPRISE'].includes(state.subscription) + + // Feature-Gates + const canExportPDF = isProfessional + const canUseRAG = isProfessional + const canCustomizeDSFA = isEnterprise + const canUseAPI = isProfessional + + return ( +
+ {canExportPDF && } + {canUseRAG && } +
+ ) +}`} +
+ +

Logging & Debugging

+

+ Aktivieren Sie detailliertes Logging fuer die Entwicklung: +

+ +{`// In Ihrer .env.local +NEXT_PUBLIC_SDK_DEBUG=true + +// Oder programmatisch + { + if (process.env.NODE_ENV === 'development') { + console.log('[SDK] State Update:', { + phase: state.currentPhase, + step: state.currentStep, + useCases: state.useCases.length, + risks: state.risks.length, + }) + } + }} +> + {children} +`} + + + + Der SDK-State ist im React DevTools unter dem SDKProvider-Context sichtbar. + Installieren Sie die React Developer Tools Browser-Extension fuer einfaches Debugging. + +
+ ) +} diff --git a/developer-portal/app/sdk/consent/api-reference/page.tsx b/developer-portal/app/sdk/consent/api-reference/page.tsx new file mode 100644 index 0000000..89599c0 --- /dev/null +++ b/developer-portal/app/sdk/consent/api-reference/page.tsx @@ -0,0 +1,482 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +function CodeBlock({ code }: { code: string }) { + return ( +
+
+ +
+
+        {code}
+      
+
+ ) +} + +function MethodCard({ + name, + signature, + description, + params, + returns, + example, +}: { + name: string + signature: string + description: string + params?: { name: string; type: string; description: string }[] + returns?: string + example?: string +}) { + return ( +
+
+ {name} +
+
+
+ {signature} +
+

{description}

+ + {params && params.length > 0 && ( +
+

Parameter

+ + + {params.map((param) => ( + + + + + + ))} + +
+ {param.name} + + {param.type} + {param.description}
+
+ )} + + {returns && ( +
+

Rueckgabe

+ {returns} +
+ )} + + {example && ( +
+

Beispiel

+ +
+ )} +
+
+ ) +} + +export default function APIReferencePage() { + return ( +
+ + +
+
+

API Referenz

+

+ Vollstaendige Dokumentation aller Methoden und Konfigurationsoptionen des Consent SDK. +

+ + {/* ConsentManager */} +
+

ConsentManager

+

+ Die zentrale Klasse fuer das Consent Management. Verwaltet Einwilligungen, Script-Blocking und Events. +

+ + {/* Constructor */} +
+ + + + + + + + + { + await consent.acceptAll(); +});`} + /> + + { + await consent.rejectAll(); +});`} + /> + + { + await consent.revokeAll(); + location.reload(); +});`} + /> + + { + console.log('Consent geaendert:', state); +}); + +// Spaeter: Listener entfernen +unsubscribe();`} + /> + + + + +
+
+ + {/* Configuration */} +
+

Konfiguration

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Option + + Typ + + Default + + Beschreibung +
+ apiEndpoint + + string + erforderlichURL des Consent-Backends
+ siteId + + string + erforderlichEindeutige Site-ID
+ debug + + boolean + falseAktiviert Debug-Logging
+ language + + string + 'de'Sprache fuer UI-Texte
+ consent.rememberDays + + number + 365Gueltigkeitsdauer in Tagen
+ consent.recheckAfterDays + + number + 180Erneute Abfrage nach X Tagen
+
+
+ + {/* Events */} +
+

Events

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Event + + Daten + + Beschreibung +
+ init + + ConsentState | null + SDK initialisiert
+ change + + ConsentState + Consent geaendert
+ accept_all + + ConsentState + Alle akzeptiert
+ reject_all + + ConsentState + Alle abgelehnt
+ banner_show + + undefined + Banner angezeigt
+ banner_hide + + undefined + Banner versteckt
+ error + + Error + Fehler aufgetreten
+
+
+ + {/* Types */} +
+

TypeScript Types

+ ; + vendors: Record; + timestamp: string; + version: string; + consentId?: string; + expiresAt?: string; +} + +// Konfiguration +interface ConsentConfig { + apiEndpoint: string; + siteId: string; + debug?: boolean; + language?: string; + fallbackLanguage?: string; + ui?: ConsentUIConfig; + consent?: ConsentBehaviorConfig; + onConsentChange?: (state: ConsentState) => void; + onBannerShow?: () => void; + onBannerHide?: () => void; + onError?: (error: Error) => void; +}`} + /> +
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/frameworks/angular/page.tsx b/developer-portal/app/sdk/consent/frameworks/angular/page.tsx new file mode 100644 index 0000000..431d64c --- /dev/null +++ b/developer-portal/app/sdk/consent/frameworks/angular/page.tsx @@ -0,0 +1,281 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function AngularIntegrationPage() { + return ( +
+ + +
+
+
+
+ A +
+

Angular Integration

+
+

+ Service und Module fuer Angular 14+ Projekte. +

+ + {/* Installation */} +
+

Installation

+ +
+ + {/* Module Setup */} +
+

Module Setup

+ +
+ + {/* Standalone Setup */} +
+

Standalone Setup (Angular 15+)

+ +
+ + {/* Service Usage */} +
+

Service Usage

+ + +
+ \`, +}) +export class AnalyticsComponent implements OnInit { + hasAnalyticsConsent$ = this.consentService.hasConsent$('analytics'); + + constructor(private consentService: ConsentService) {} + + async loadAnalytics() { + if (await this.consentService.hasConsent('analytics')) { + // Load analytics + } + } +}`} + /> + + + {/* Cookie Banner */} +
+

Cookie Banner Component

+ +
+

+ Wir verwenden Cookies um Ihr Erlebnis zu verbessern. +

+
+ + + +
+
+
+ \`, +}) +export class CookieBannerComponent { + isBannerVisible$ = this.consentService.isBannerVisible$; + + constructor(private consentService: ConsentService) {} + + async acceptAll() { + await this.consentService.acceptAll(); + } + + async rejectAll() { + await this.consentService.rejectAll(); + } + + showSettings() { + this.consentService.showSettings(); + } +}`} + /> + + + {/* Directive */} +
+

ConsentGate Directive

+ + + + +
+ +
+ +
+

Bitte stimmen Sie Statistik-Cookies zu.

+ +
+
`} + /> +
+ + {/* Service API */} +
+

Service API

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Property/Method + + Typ + + Beschreibung +
consent$Observable<ConsentState>Observable des aktuellen Consent
hasConsent$()Observable<boolean>Reaktive Consent-Pruefung
hasConsent()Promise<boolean>Async Consent-Pruefung
isBannerVisible$Observable<boolean>Banner-Sichtbarkeit
acceptAll()Promise<void>Akzeptiert alle
rejectAll()Promise<void>Lehnt alle ab
setConsent()Promise<void>Setzt spezifische Kategorien
+
+
+
+ +
+ ) +} diff --git a/developer-portal/app/sdk/consent/frameworks/page.tsx b/developer-portal/app/sdk/consent/frameworks/page.tsx new file mode 100644 index 0000000..f2df7a7 --- /dev/null +++ b/developer-portal/app/sdk/consent/frameworks/page.tsx @@ -0,0 +1,98 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { ChevronRight } from 'lucide-react' + +const frameworks = [ + { + name: 'React', + href: '/sdk/consent/frameworks/react', + logo: '/logos/react.svg', + description: 'Hooks und Provider fuer React 17+ und Next.js', + features: ['ConsentProvider', 'useConsent Hook', 'ConsentGate Component'], + color: 'bg-cyan-500', + }, + { + name: 'Vue 3', + href: '/sdk/consent/frameworks/vue', + logo: '/logos/vue.svg', + description: 'Composables und Plugin fuer Vue 3 und Nuxt', + features: ['Vue Plugin', 'useConsent Composable', 'ConsentGate Component'], + color: 'bg-emerald-500', + }, + { + name: 'Angular', + href: '/sdk/consent/frameworks/angular', + logo: '/logos/angular.svg', + description: 'Service und Module fuer Angular 14+', + features: ['ConsentService', 'ConsentModule', 'Dependency Injection'], + color: 'bg-red-500', + }, +] + +export default function FrameworksPage() { + return ( +
+ + +
+
+

Framework Integration

+

+ Das Consent SDK bietet native Integrationen fuer alle gaengigen Frontend-Frameworks. +

+ +
+ {frameworks.map((framework) => ( + +
+
+ {framework.name[0]} +
+
+
+

+ {framework.name} +

+ +
+

{framework.description}

+
+ {framework.features.map((feature) => ( + + {feature} + + ))} +
+
+
+ + ))} +
+ + {/* Vanilla JS Note */} +
+

Vanilla JavaScript

+

+ Sie koennen das SDK auch ohne Framework verwenden. Importieren Sie einfach den ConsentManager direkt + aus dem Hauptpaket. Siehe{' '} + + Installation + {' '} + fuer Details. +

+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/frameworks/react/page.tsx b/developer-portal/app/sdk/consent/frameworks/react/page.tsx new file mode 100644 index 0000000..4b44249 --- /dev/null +++ b/developer-portal/app/sdk/consent/frameworks/react/page.tsx @@ -0,0 +1,277 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function ReactIntegrationPage() { + return ( +
+ + +
+
+
+
+ R +
+

React Integration

+
+

+ Hooks und Provider fuer React 17+ und Next.js Projekte. +

+ + {/* Installation */} +
+

Installation

+ +
+ + {/* Provider Setup */} +
+

Provider Setup

+

+ Umschliessen Sie Ihre App mit dem ConsentProvider: +

+ + + + {children} + + + + ); +}`} + /> +
+ + {/* useConsent Hook */} +
+

useConsent Hook

+

+ Verwenden Sie den Hook in jeder Komponente: +

+ + ); +}`} + /> +
+ + {/* ConsentGate */} +
+

ConsentGate Component

+

+ Zeigt Inhalte nur wenn Consent vorhanden ist: +

+ +

Video erfordert Ihre Zustimmung.

+ +
+ } + > + `} + /> + + + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Anforderung + + Minimum +
Node.js>= 18.0.0
React (optional)>= 17.0.0
Vue (optional)>= 3.0.0
TypeScript (optional)>= 4.7.0
+
+
+ + {/* Browser Support */} +
+

Browser-Unterstuetzung

+ + Das SDK unterstuetzt alle modernen Browser mit ES2017+ Unterstuetzung. + Fuer aeltere Browser wird ein automatischer Fallback fuer Crypto-Funktionen bereitgestellt. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Browser + + Minimum Version +
Chrome>= 60
Firefox>= 55
Safari>= 11
Edge>= 79 (Chromium)
+
+
+ + {/* Next Steps */} +
+

Naechste Schritte

+ +
+
+ +
+ ) +} diff --git a/developer-portal/app/sdk/consent/mobile/android/page.tsx b/developer-portal/app/sdk/consent/mobile/android/page.tsx new file mode 100644 index 0000000..04f240d --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/android/page.tsx @@ -0,0 +1,269 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check, Smartphone } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function AndroidSDKPage() { + return ( +
+ + +
+
+
+
+ +
+

Android SDK (Kotlin)

+
+

+ Native Kotlin SDK fuer Android API 26+ mit Jetpack Compose Unterstuetzung. +

+ + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + +
Kotlin Version1.9+
Min SDKAPI 26 (Android 8.0)
Compile SDK34+
+
+
+ + {/* Installation */} +
+

Installation

+ +
+ + {/* Basic Setup */} +
+

Grundlegende Einrichtung

+ +
+ + {/* Jetpack Compose */} +
+

Jetpack Compose Integration

+ +
+ + {/* Traditional Android */} +
+

View-basierte Integration

+ + updateUI(state) + } + } + + // Banner anzeigen wenn noetig + if (ConsentManager.needsConsent()) { + ConsentManager.showBanner(this) + } + } + + private fun updateUI(state: ConsentState?) { + if (state?.hasConsent(ConsentCategory.ANALYTICS) == true) { + loadAnalytics() + } + } + + fun onAcceptAllClick(view: View) { + lifecycleScope.launch { + ConsentManager.acceptAll() + } + } +}`} + /> +
+ + {/* API Reference */} +
+

API Referenz

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodeBeschreibung
configure()SDK konfigurieren
initialize()SDK initialisieren (suspend)
hasConsent()Consent fuer Kategorie pruefen
consentFlowFlow fuer reaktive Updates
acceptAll()Alle akzeptieren (suspend)
rejectAll()Alle ablehnen (suspend)
setConsent()Kategorien setzen (suspend)
showBanner()Banner als DialogFragment
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/mobile/flutter/page.tsx b/developer-portal/app/sdk/consent/mobile/flutter/page.tsx new file mode 100644 index 0000000..1f713ad --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/flutter/page.tsx @@ -0,0 +1,313 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check, Smartphone } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function FlutterSDKPage() { + return ( +
+ + +
+
+
+
+ +
+

Flutter SDK

+
+

+ Cross-Platform SDK fuer Flutter 3.16+ mit iOS, Android und Web Support. +

+ + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + +
Dart Version3.0+
Flutter Version3.16+
PlattformeniOS, Android, Web
+
+
+ + {/* Installation */} +
+

Installation

+ +
+ +
+
+ + {/* Basic Setup */} +
+

Grundlegende Einrichtung

+ +
+ + {/* Widget Usage */} +
+

Widget Integration

+ ( + stream: ConsentManager.instance.consentStream, + builder: (context, snapshot) { + final consent = snapshot.data; + + if (consent?.hasConsent(ConsentCategory.analytics) ?? false) { + return const AnalyticsWidget(); + } + + return const SizedBox.shrink(); + }, + ), + + // ConsentGate Widget + ConsentGate( + category: ConsentCategory.marketing, + fallback: const Center( + child: Text('Marketing-Zustimmung erforderlich'), + ), + child: const MarketingWidget(), + ), + + // Buttons + ElevatedButton( + onPressed: () => ConsentManager.instance.acceptAll(), + child: const Text('Alle akzeptieren'), + ), + + ElevatedButton( + onPressed: () => ConsentManager.instance.rejectAll(), + child: const Text('Alle ablehnen'), + ), + + TextButton( + onPressed: () => ConsentManager.instance.showSettings(context), + child: const Text('Einstellungen'), + ), + ], + ), + ); + } +}`} + /> +
+ + {/* Custom Banner */} +
+

Custom Cookie Banner

+ ( + stream: ConsentManager.instance.isBannerVisibleStream, + builder: (context, snapshot) { + if (!(snapshot.data ?? false)) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + ), + ], + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Wir verwenden Cookies um Ihr Erlebnis zu verbessern.', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => ConsentManager.instance.rejectAll(), + child: const Text('Ablehnen'), + ), + TextButton( + onPressed: () => ConsentManager.instance.showSettings(context), + child: const Text('Einstellungen'), + ), + ElevatedButton( + onPressed: () => ConsentManager.instance.acceptAll(), + child: const Text('Alle akzeptieren'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +}`} + /> +
+ + {/* API Reference */} +
+

API Referenz

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Methode/PropertyBeschreibung
initialize()SDK initialisieren (Future)
hasConsent()Consent pruefen
consentStreamStream fuer Consent-Updates
isBannerVisibleStreamStream fuer Banner-Sichtbarkeit
acceptAll()Alle akzeptieren (Future)
rejectAll()Alle ablehnen (Future)
setConsent()Kategorien setzen (Future)
showSettings()Einstellungs-Dialog oeffnen
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/mobile/ios/page.tsx b/developer-portal/app/sdk/consent/mobile/ios/page.tsx new file mode 100644 index 0000000..62db91f --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/ios/page.tsx @@ -0,0 +1,283 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check, Apple } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function iOSSDKPage() { + return ( +
+ + +
+
+
+
+ +
+

iOS SDK (Swift)

+
+

+ Native Swift SDK fuer iOS 15+ und iPadOS mit SwiftUI-Unterstuetzung. +

+ + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + +
Swift Version5.9+
iOS Deployment TargetiOS 15.0+
Xcode Version15.0+
+
+
+ + {/* Installation */} +
+

Installation

+

Swift Package Manager

+ +

+ Oder in Xcode: File → Add Package Dependencies → URL eingeben +

+
+ + {/* Basic Usage */} +
+

Grundlegende Verwendung

+ Bool { + + // Consent Manager konfigurieren + ConsentManager.shared.configure( + apiEndpoint: "https://api.example.com/consent", + siteId: "my-ios-app" + ) + + // Initialisieren + Task { + await ConsentManager.shared.initialize() + } + + return true + } +}`} + /> +
+ + {/* SwiftUI Integration */} +
+

SwiftUI Integration

+ +
+ + {/* UIKit Integration */} +
+

UIKit Integration

+ () + + override func viewDidLoad() { + super.viewDidLoad() + + // Reaktiv auf Consent-Aenderungen reagieren + ConsentManager.shared.$consent + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.updateUI(consent: state) + } + .store(in: &cancellables) + } + + private func updateUI(consent: ConsentState?) { + if consent?.hasConsent(.analytics) == true { + loadAnalytics() + } + } + + @IBAction func acceptAllTapped(_ sender: UIButton) { + Task { + await ConsentManager.shared.acceptAll() + } + } +}`} + /> +
+ + {/* Consent Categories */} +
+

Consent-Kategorien

+ +
+ + {/* API Reference */} +
+

API Referenz

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodeBeschreibung
configure()SDK konfigurieren
initialize()SDK initialisieren (async)
hasConsent(_:)Consent fuer Kategorie pruefen
acceptAll()Alle Kategorien akzeptieren (async)
rejectAll()Alle ablehnen (async)
setConsent(_:)Spezifische Kategorien setzen (async)
showBanner()Banner anzeigen
exportConsent()Consent-Daten exportieren (DSGVO)
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/mobile/page.tsx b/developer-portal/app/sdk/consent/mobile/page.tsx new file mode 100644 index 0000000..6173669 --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/page.tsx @@ -0,0 +1,95 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { ChevronRight, Apple, Smartphone } from 'lucide-react' + +const platforms = [ + { + name: 'iOS (Swift)', + href: '/sdk/consent/mobile/ios', + description: 'Native Swift SDK fuer iOS 15+ und iPadOS', + features: ['Swift 5.9+', 'iOS 15.0+', 'SwiftUI Support', 'Combine Integration'], + color: 'bg-gray-900', + icon: Apple, + }, + { + name: 'Android (Kotlin)', + href: '/sdk/consent/mobile/android', + description: 'Native Kotlin SDK fuer Android API 26+', + features: ['Kotlin 1.9+', 'API 26+', 'Jetpack Compose', 'Coroutines'], + color: 'bg-green-600', + icon: Smartphone, + }, + { + name: 'Flutter', + href: '/sdk/consent/mobile/flutter', + description: 'Cross-Platform SDK fuer Flutter 3.16+', + features: ['Dart 3.0+', 'Flutter 3.16+', 'iOS & Android', 'Web Support'], + color: 'bg-blue-500', + icon: Smartphone, + }, +] + +export default function MobileSDKsPage() { + return ( +
+ + +
+
+

Mobile SDKs

+

+ Native SDKs fuer iOS, Android und Flutter mit vollstaendiger DSGVO-Konformitaet. +

+ +
+ {platforms.map((platform) => ( + +
+
+ +
+
+
+

+ {platform.name} +

+ +
+

{platform.description}

+
+ {platform.features.map((feature) => ( + + {feature} + + ))} +
+
+
+ + ))} +
+ + {/* Cross-Platform Note */} +
+

Cross-Platform Konsistenz

+

+ Alle Mobile SDKs bieten dieselbe API-Oberflaeche wie das Web SDK. + Consent-Daten werden ueber die API synchronisiert, sodass Benutzer auf allen Geraeten + denselben Consent-Status haben. +

+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/page.tsx b/developer-portal/app/sdk/consent/page.tsx new file mode 100644 index 0000000..3d0caff --- /dev/null +++ b/developer-portal/app/sdk/consent/page.tsx @@ -0,0 +1,262 @@ +'use client' + +import React, { useState } from 'react' +import Link from 'next/link' +import { + Shield, Code, Download, Smartphone, FileCode, Lock, + ChevronRight, Copy, Check, Zap, Globe, Layers, + BookOpen, Terminal +} from 'lucide-react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' + +type Framework = 'npm' | 'yarn' | 'pnpm' + +const installCommands: Record = { + npm: 'npm install @breakpilot/consent-sdk', + yarn: 'yarn add @breakpilot/consent-sdk', + pnpm: 'pnpm add @breakpilot/consent-sdk', +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +export default function ConsentSDKHubPage() { + const [selectedPM, setSelectedPM] = useState('npm') + + const quickLinks = [ + { + title: 'Installation', + description: 'SDK in wenigen Minuten einrichten', + href: '/sdk/consent/installation', + icon: Download, + color: 'bg-blue-500', + }, + { + title: 'API Referenz', + description: 'Vollstaendige API-Dokumentation', + href: '/sdk/consent/api-reference', + icon: FileCode, + color: 'bg-purple-500', + }, + { + title: 'Frameworks', + description: 'React, Vue, Angular Integration', + href: '/sdk/consent/frameworks', + icon: Layers, + color: 'bg-green-500', + }, + { + title: 'Mobile SDKs', + description: 'iOS, Android, Flutter', + href: '/sdk/consent/mobile', + icon: Smartphone, + color: 'bg-orange-500', + }, + { + title: 'Sicherheit', + description: 'Best Practices & Compliance', + href: '/sdk/consent/security', + icon: Lock, + color: 'bg-red-500', + }, + ] + + const features = [ + { + title: 'DSGVO & TTDSG Konform', + description: 'Vollstaendige Unterstuetzung fuer EU-Datenschutzverordnungen mit revisionssicherer Consent-Speicherung.', + icon: Shield, + }, + { + title: 'Google Consent Mode v2', + description: 'Native Integration mit automatischer Synchronisation zu Google Analytics und Ads.', + icon: Globe, + }, + { + title: 'Script Blocking', + description: 'Automatisches Blockieren von Third-Party Scripts bis zur Einwilligung.', + icon: Code, + }, + { + title: 'Multi-Platform', + description: 'Unterstuetzung fuer Web, PWA, iOS, Android und Flutter aus einer Codebasis.', + icon: Smartphone, + }, + ] + + return ( +
+ + +
+
+ {/* Header */} +
+
+
+ +
+
+

Consent SDK

+
+ + v1.0.0 + + DSGVO/TTDSG Compliant +
+
+
+

+ Das Consent SDK ermoeglicht DSGVO-konforme Einwilligungsverwaltung fuer Web, PWA und Mobile Apps. + Mit nativer Unterstuetzung fuer React, Vue, Angular und Mobile Platforms. +

+
+ + {/* Quick Install */} +
+
+

Schnellinstallation

+
+ {(['npm', 'yarn', 'pnpm'] as const).map((pm) => ( + + ))} +
+
+
+ + $ {installCommands[selectedPM]} + + +
+
+ + {/* Quick Links */} +
+

Dokumentation

+
+ {quickLinks.map((link) => ( + +
+
+ +
+
+

+ {link.title} + +

+

{link.description}

+
+
+ + ))} +
+
+ + {/* Quick Start Code */} +
+
+

Schnellstart

+
+
+
+{`import { ConsentManager } from '@breakpilot/consent-sdk';
+
+// Manager initialisieren
+const consent = new ConsentManager({
+  apiEndpoint: 'https://api.example.com/consent',
+  siteId: 'your-site-id',
+});
+
+// SDK starten
+await consent.init();
+
+// Consent pruefen
+if (consent.hasConsent('analytics')) {
+  // Analytics laden
+}
+
+// Events abonnieren
+consent.on('change', (state) => {
+  console.log('Consent geaendert:', state);
+});`}
+              
+
+
+ + {/* Features */} +
+

Features

+
+ {features.map((feature) => ( +
+
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+
+ ))} +
+
+ + {/* Compliance Notice */} +
+
+ +
+

DSGVO & TTDSG Compliance

+

+ Das Consent SDK erfuellt alle Anforderungen der DSGVO (Art. 6, 7, 13, 14, 17, 20) und des TTDSG (§ 25). + Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit exportiert werden. +

+
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/security/page.tsx b/developer-portal/app/sdk/consent/security/page.tsx new file mode 100644 index 0000000..9ca2bec --- /dev/null +++ b/developer-portal/app/sdk/consent/security/page.tsx @@ -0,0 +1,290 @@ +'use client' + +import React from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Shield, Lock, Eye, Database, Key, AlertTriangle, CheckCircle } from 'lucide-react' + +function SecurityCard({ + title, + description, + icon: Icon, + items, +}: { + title: string + description: string + icon: React.ComponentType<{ className?: string }> + items: string[] +}) { + return ( +
+
+
+ +
+
+

{title}

+

{description}

+
    + {items.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+ ) +} + +export default function SecurityPage() { + return ( +
+ + +
+
+

Sicherheit & Compliance

+

+ Best Practices fuer sichere Implementierung und DSGVO-konforme Nutzung des Consent SDK. +

+ + {/* Security Features */} +
+

Sicherheits-Features

+
+ + + + + + + +
+
+ + {/* DSGVO Compliance */} +
+

DSGVO Compliance

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ DSGVO Artikel + + Anforderung + + SDK-Unterstuetzung +
Art. 6Rechtmaessigkeit der Verarbeitung + + Vollstaendig + +
Art. 7Bedingungen fuer Einwilligung + + Vollstaendig + +
Art. 13/14Informationspflichten + + Vollstaendig + +
Art. 17Recht auf Loeschung + + Vollstaendig + +
Art. 20Datenportabilitaet + + Vollstaendig + +
+
+
+ + {/* TTDSG Compliance */} +
+

TTDSG Compliance

+
+
+
+ +
+
+

§ 25 TTDSG - Schutz der Privatsphaere

+

+ Das SDK erfuellt alle Anforderungen des § 25 TTDSG (Telemediengesetz): +

+
    +
  • + + + Einwilligung vor Speicherung: Cookies und localStorage werden erst nach + Einwilligung gesetzt (ausser technisch notwendige). + +
  • +
  • + + + Informierte Einwilligung: Klare Kategorisierung und Beschreibung + aller Cookies und Tracker. + +
  • +
  • + + + Widerrufsrecht: Jederzeit widerrufbare Einwilligung mit einem Klick. + +
  • +
+
+
+
+
+ + {/* Best Practices */} +
+

Best Practices

+ +
+
+

+ + Empfohlen +

+
    +
  • • HTTPS fuer alle API-Aufrufe verwenden
  • +
  • • Consent-Banner vor dem Laden von Third-Party Scripts anzeigen
  • +
  • • Alle Kategorien klar und verstaendlich beschreiben
  • +
  • • Ablehnen-Button gleichwertig zum Akzeptieren-Button darstellen
  • +
  • • Consent-Aenderungen serverseitig protokollieren
  • +
  • • Regelmaessige Ueberpruefung der Consent-Gultigkeit (recheckAfterDays)
  • +
+
+ +
+

+ + Vermeiden +

+
    +
  • • Dark Patterns (versteckte Ablehnen-Buttons)
  • +
  • • Pre-checked Consent-Optionen
  • +
  • • Tracking vor Einwilligung
  • +
  • • Cookie-Walls ohne echte Alternative
  • +
  • • Unklare oder irrefuehrende Kategoriebezeichnungen
  • +
+
+
+
+ + {/* Audit Trail */} +
+

Audit Trail

+
+

+ Das SDK speichert fuer jeden Consent-Vorgang revisionssichere Daten: +

+
+
+{`{
+  "consentId": "consent_abc123...",
+  "timestamp": "2024-01-15T10:30:00.000Z",
+  "categories": {
+    "essential": true,
+    "analytics": true,
+    "marketing": false
+  },
+  "metadata": {
+    "userAgent": "Mozilla/5.0...",
+    "language": "de-DE",
+    "platform": "web",
+    "screenResolution": "1920x1080"
+  },
+  "signature": "sha256=...",
+  "version": "1.0.0"
+}`}
+                
+
+

+ Diese Daten werden sowohl lokal als auch auf dem Server gespeichert und koennen + jederzeit fuer Audits exportiert werden. +

+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/installation/page.tsx b/developer-portal/app/sdk/installation/page.tsx new file mode 100644 index 0000000..df7771f --- /dev/null +++ b/developer-portal/app/sdk/installation/page.tsx @@ -0,0 +1,186 @@ +import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/DevPortalLayout' + +export default function SDKInstallationPage() { + return ( + +

Voraussetzungen

+
    +
  • Node.js 18 oder hoeher
  • +
  • React 18+ / Next.js 14+
  • +
  • TypeScript 5.0+ (empfohlen)
  • +
+ +

Installation

+

+ Installieren Sie das SDK ueber Ihren bevorzugten Paketmanager: +

+ +{`npm install @breakpilot/compliance-sdk`} + + +{`yarn add @breakpilot/compliance-sdk`} + + +{`pnpm add @breakpilot/compliance-sdk`} + + +

Peer Dependencies

+

+ Das SDK hat folgende Peer Dependencies, die automatisch installiert werden sollten: +

+ +{`{ + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } +}`} + + +

Zusaetzliche Pakete (optional)

+

+ Fuer erweiterte Funktionen koennen Sie folgende Pakete installieren: +

+ + +

TypeScript Konfiguration

+

+ Das SDK ist vollstaendig in TypeScript geschrieben. Stellen Sie sicher, + dass Ihre tsconfig.json folgende Optionen enthaelt: +

+ +{`{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +}`} + + +

Next.js Integration

+

+ Fuer Next.js 14+ mit App Router, fuegen Sie den Provider in Ihr Root-Layout ein: +

+ +{`import { SDKProvider } from '@breakpilot/compliance-sdk' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +}`} + + + + Der SDKProvider ist ein Client-Component. Wenn Sie Server Components + verwenden, wrappen Sie nur die Teile der App, die das SDK benoetigen. + + +

Umgebungsvariablen

+

+ Erstellen Sie eine .env.local Datei mit folgenden Variablen: +

+ +{`# Pflicht +NEXT_PUBLIC_TENANT_ID=your-tenant-id + +# Optional (fuer Backend-Sync) +BREAKPILOT_API_KEY=sk_live_... + +# Optional (fuer Self-Hosted) +NEXT_PUBLIC_SDK_API_URL=https://your-server.com/sdk/v1`} + + + + Der API Key sollte niemals im Frontend-Code oder in NEXT_PUBLIC_ Variablen + erscheinen. Verwenden Sie Server-Side API Routes fuer authentifizierte Anfragen. + + +

Verifizierung

+

+ Testen Sie die Installation mit einer einfachen Komponente: +

+ +{`'use client' + +import { useSDK } from '@breakpilot/compliance-sdk' + +export default function TestPage() { + const { state, completionPercentage } = useSDK() + + return ( +
+

SDK Test

+

Fortschritt: {completionPercentage}%

+

Aktuelle Phase: {state.currentPhase}

+

Use Cases: {state.useCases.length}

+
+ ) +}`} +
+ +

Fehlerbehebung

+ +

Error: useSDK must be used within SDKProvider

+

+ Stellen Sie sicher, dass der SDKProvider das gesamte Layout umschliesst + und dass Sie {'\'use client\''} in Client-Komponenten verwenden. +

+ +

Error: Module not found

+

+ Loeschen Sie node_modules und package-lock.json, dann reinstallieren: +

+ +{`rm -rf node_modules package-lock.json +npm install`} + + +

TypeScript Errors

+

+ Stellen Sie sicher, dass TypeScript 5.0+ installiert ist: +

+ +{`npm install typescript@latest`} + +
+ ) +} diff --git a/developer-portal/app/sdk/page.tsx b/developer-portal/app/sdk/page.tsx new file mode 100644 index 0000000..5df3e16 --- /dev/null +++ b/developer-portal/app/sdk/page.tsx @@ -0,0 +1,281 @@ +import Link from 'next/link' +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function SDKOverviewPage() { + return ( + +

Übersicht

+

+ Das AI Compliance SDK ist ein TypeScript-Paket für die Integration des + Compliance-Workflows in React und Next.js Anwendungen. Es bietet: +

+
    +
  • React Context Provider für State Management
  • +
  • Hooks für einfachen Zugriff auf Compliance-Daten
  • +
  • Automatische Synchronisation mit dem Backend
  • +
  • Offline-Support mit localStorage Fallback
  • +
  • Export-Funktionen (PDF, JSON, ZIP)
  • +
+ +

Kernkomponenten

+ +

SDKProvider

+

+ Der Provider wrappet Ihre App und stellt den SDK-Kontext bereit: +

+ +{`import { SDKProvider } from '@breakpilot/compliance-sdk' + +export default function Layout({ children }) { + return ( + + {children} + + ) +}`} + + +

useSDK Hook

+

+ Der Haupt-Hook für den Zugriff auf alle SDK-Funktionen: +

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function MyComponent() { + const { + // State + state, + dispatch, + + // Navigation + currentStep, + goToStep, + goToNextStep, + goToPreviousStep, + canGoNext, + canGoPrevious, + + // Progress + completionPercentage, + phase1Completion, + phase2Completion, + + // Checkpoints + validateCheckpoint, + overrideCheckpoint, + getCheckpointStatus, + + // Data Updates + updateUseCase, + addRisk, + updateControl, + + // Persistence + saveState, + loadState, + + // Demo Data + seedDemoData, + clearDemoData, + isDemoDataLoaded, + + // Sync + syncState, + forceSyncToServer, + isOnline, + + // Export + exportState, + + // Command Bar + isCommandBarOpen, + setCommandBarOpen, + } = useSDK() + + return ( +
+

Progress: {completionPercentage}%

+ +
+ ) +}`} +
+ +

Types

+

+ Das SDK exportiert alle TypeScript-Types für volle Typsicherheit: +

+ +{`import type { + // Core Types + SDKState, + SDKAction, + SDKStep, + SDKPhase, + + // Use Cases + UseCaseAssessment, + AssessmentResult, + + // Risk Management + Risk, + RiskSeverity, + RiskMitigation, + + // Controls & Evidence + Control, + Evidence, + Requirement, + + // Checkpoints + Checkpoint, + CheckpointStatus, + ValidationError, + + // DSFA + DSFA, + DSFASection, + DSFAApproval, + + // TOMs & VVT + TOM, + ProcessingActivity, + RetentionPolicy, + + // AI Act + AIActResult, + AIActRiskCategory, +} from '@breakpilot/compliance-sdk'`} + + +

Utility Functions

+

+ Hilfreiche Funktionen für die Arbeit mit dem SDK: +

+ +{`import { + // Step Navigation + getStepById, + getStepByUrl, + getNextStep, + getPreviousStep, + getStepsForPhase, + + // Risk Calculation + calculateRiskScore, + getRiskSeverityFromScore, + calculateResidualRisk, + + // Progress + getCompletionPercentage, + getPhaseCompletionPercentage, +} from '@breakpilot/compliance-sdk' + +// Beispiel: Risk Score berechnen +const inherentRisk = calculateRiskScore(4, 5) // likelihood * impact = 20 +const severity = getRiskSeverityFromScore(20) // 'CRITICAL'`} + + +

API Client

+

+ Für direkten API-Zugriff ohne React Context: +

+ +{`import { + getSDKApiClient, + SDKApiClient, +} from '@breakpilot/compliance-sdk' + +const client = getSDKApiClient('your-tenant-id') + +// State laden +const state = await client.getState() + +// State speichern +await client.saveState(updatedState) + +// Checkpoint validieren +const result = await client.validateCheckpoint('CP-UC', state) + +// Export +const blob = await client.exportState('pdf')`} + + +

RAG & LLM Client

+

+ Zugriff auf die RAG-Suche und Dokumentengenerierung: +

+ +{`import { + getSDKBackendClient, + isLegalQuery, +} from '@breakpilot/compliance-sdk' + +const client = getSDKBackendClient() + +// RAG-Suche +const results = await client.search('DSGVO Art. 5', 5) +console.log(results) // SearchResult[] + +// Dokumentengenerierung +const dsfa = await client.generateDSFA(context) +const toms = await client.generateTOM(context) +const vvt = await client.generateVVT(context) + +// Prüfen ob eine Query rechtliche Inhalte betrifft +if (isLegalQuery('Einwilligung DSGVO')) { + // RAG-Suche durchführen +}`} + + +

Export

+

+ Exportieren Sie Compliance-Daten in verschiedenen Formaten: +

+ +{`import { exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk' + +// PDF Export +const pdfBlob = await exportToPDF(state) +downloadExport(pdfBlob, 'compliance-report.pdf') + +// ZIP Export (alle Dokumente) +const zipBlob = await exportToZIP(state) +downloadExport(zipBlob, 'compliance-export.zip') + +// Über den Hook +const { exportState } = useSDK() +const blob = await exportState('pdf') // 'json' | 'pdf' | 'zip'`} + + + +
    +
  • + + Installation Guide + +
  • +
  • + + Konfigurationsoptionen + +
  • +
  • + + Phase 1 Workflow Guide + +
  • +
+
+
+ ) +} diff --git a/developer-portal/components/DevPortalLayout.tsx b/developer-portal/components/DevPortalLayout.tsx new file mode 100644 index 0000000..0d0bc97 --- /dev/null +++ b/developer-portal/components/DevPortalLayout.tsx @@ -0,0 +1,313 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { Book, Code, FileText, HelpCircle, Zap, Terminal, Database, Shield, ChevronRight, Clock } from 'lucide-react' + +interface NavItem { + title: string + href: string + icon?: React.ReactNode + items?: NavItem[] +} + +const navigation: NavItem[] = [ + { + title: 'Getting Started', + href: '/getting-started', + icon: , + items: [ + { title: 'Quick Start', href: '/getting-started' }, + ], + }, + { + title: 'SDK Documentation', + href: '/sdk', + icon: , + items: [ + { title: 'Overview', href: '/sdk' }, + { title: 'Installation', href: '/sdk/installation' }, + { title: 'Configuration', href: '/sdk/configuration' }, + ], + }, + { + title: 'Consent SDK', + href: '/sdk/consent', + icon: , + items: [ + { title: 'Uebersicht', href: '/sdk/consent' }, + { title: 'Installation', href: '/sdk/consent/installation' }, + { title: 'API Referenz', href: '/sdk/consent/api-reference' }, + { title: 'Frameworks', href: '/sdk/consent/frameworks' }, + { title: 'Mobile SDKs', href: '/sdk/consent/mobile' }, + { title: 'Sicherheit', href: '/sdk/consent/security' }, + ], + }, + { + title: 'API Reference', + href: '/api', + icon: , + items: [ + { title: 'Overview', href: '/api' }, + { title: 'State API', href: '/api/state' }, + { title: 'RAG Search API', href: '/api/rag' }, + { title: 'Generation API', href: '/api/generate' }, + { title: 'Export API', href: '/api/export' }, + ], + }, + { + title: 'Guides', + href: '/guides', + icon: , + items: [ + { title: 'Overview', href: '/guides' }, + { title: 'Phase 1: Assessment', href: '/guides/phase1' }, + { title: 'Phase 2: Dokumentation', href: '/guides/phase2' }, + ], + }, + { + title: 'Changelog', + href: '/changelog', + icon: , + items: [ + { title: 'Versionshistorie', href: '/changelog' }, + ], + }, +] + +interface DevPortalLayoutProps { + children: React.ReactNode + title?: string + description?: string +} + +export function DevPortalLayout({ children, title, description }: DevPortalLayoutProps) { + const pathname = usePathname() + + return ( +
+ {/* Header */} +
+
+
+
+ +
+ +
+ Developer Portal + + | + AI Compliance SDK +
+
+ + SDK Dashboard + + + GitHub + +
+
+
+
+ +
+ {/* Sidebar */} + + + {/* Main Content */} +
+
+ {(title || description) && ( +
+ {title && ( +

{title}

+ )} + {description && ( +

{description}

+ )} +
+ )} +
+ {children} +
+
+
+
+
+ ) +} + +// Re-usable components for documentation +export function ApiEndpoint({ + method, + path, + description, +}: { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' + path: string + description: string +}) { + const methodColors = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-yellow-100 text-yellow-800', + DELETE: 'bg-red-100 text-red-800', + } + + return ( +
+
+ + {method} + + {path} +
+

{description}

+
+ ) +} + +export function CodeBlock({ + language, + children, + filename, +}: { + language: string + children: string + filename?: string +}) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+        {children}
+      
+
+ ) +} + +export function ParameterTable({ + parameters, +}: { + parameters: Array<{ + name: string + type: string + required?: boolean + description: string + }> +}) { + return ( +
+ + + + + + + + + + + {parameters.map((param) => ( + + + + + + + ))} + +
ParameterTypeRequiredDescription
+ {param.name} + + {param.type} + + {param.required ? ( + Yes + ) : ( + No + )} + {param.description}
+
+ ) +} + +export function InfoBox({ + type = 'info', + title, + children, +}: { + type?: 'info' | 'warning' | 'success' | 'error' + title?: string + children: React.ReactNode +}) { + const styles = { + info: 'bg-blue-50 border-blue-200 text-blue-800', + warning: 'bg-yellow-50 border-yellow-200 text-yellow-800', + success: 'bg-green-50 border-green-200 text-green-800', + error: 'bg-red-50 border-red-200 text-red-800', + } + + const icons = { + info: , + warning: , + success: , + error: , + } + + return ( +
+
+
{icons[type]}
+
+ {title &&

{title}

} +
{children}
+
+
+
+ ) +} diff --git a/developer-portal/components/SDKDocsSidebar.tsx b/developer-portal/components/SDKDocsSidebar.tsx new file mode 100644 index 0000000..ca8fbf3 --- /dev/null +++ b/developer-portal/components/SDKDocsSidebar.tsx @@ -0,0 +1,165 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + Shield, Download, FileCode, Layers, Smartphone, Lock, + ChevronDown, ChevronRight, Home, BookOpen, + Code2 +} from 'lucide-react' + +interface NavItem { + title: string + href: string + icon?: React.ReactNode + children?: NavItem[] +} + +const navigation: NavItem[] = [ + { + title: 'Uebersicht', + href: '/sdk/consent', + icon: , + }, + { + title: 'Installation', + href: '/sdk/consent/installation', + icon: , + }, + { + title: 'API Referenz', + href: '/sdk/consent/api-reference', + icon: , + }, + { + title: 'Frameworks', + href: '/sdk/consent/frameworks', + icon: , + children: [ + { title: 'React', href: '/sdk/consent/frameworks/react' }, + { title: 'Vue', href: '/sdk/consent/frameworks/vue' }, + { title: 'Angular', href: '/sdk/consent/frameworks/angular' }, + ], + }, + { + title: 'Mobile SDKs', + href: '/sdk/consent/mobile', + icon: , + children: [ + { title: 'iOS (Swift)', href: '/sdk/consent/mobile/ios' }, + { title: 'Android (Kotlin)', href: '/sdk/consent/mobile/android' }, + { title: 'Flutter', href: '/sdk/consent/mobile/flutter' }, + ], + }, + { + title: 'Sicherheit', + href: '/sdk/consent/security', + icon: , + }, +] + +function NavLink({ item, depth = 0 }: { item: NavItem; depth?: number }) { + const pathname = usePathname() + const isActive = pathname === item.href + const isParentActive = item.children?.some((child) => pathname === child.href) + const [isOpen, setIsOpen] = React.useState(isActive || isParentActive) + + const hasChildren = item.children && item.children.length > 0 + + return ( +
+
+ + {item.icon && {item.icon}} + {item.title} + + {hasChildren && ( + + )} +
+ {hasChildren && isOpen && ( +
+ {item.children?.map((child) => ( + + ))} +
+ )} +
+ ) +} + +export function SDKDocsSidebar() { + return ( + + ) +} + +export default SDKDocsSidebar diff --git a/developer-portal/next.config.js b/developer-portal/next.config.js new file mode 100644 index 0000000..f0eab76 --- /dev/null +++ b/developer-portal/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + reactStrictMode: true, + typescript: { + ignoreBuildErrors: true, + }, +} + +module.exports = nextConfig diff --git a/developer-portal/package.json b/developer-portal/package.json new file mode 100644 index 0000000..9bf7020 --- /dev/null +++ b/developer-portal/package.json @@ -0,0 +1,25 @@ +{ + "name": "breakpilot-developer-portal", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3006", + "build": "next build", + "start": "next start -p 3006" + }, + "dependencies": { + "lucide-react": "^0.468.0", + "next": "^15.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.16", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.7.2" + } +} diff --git a/developer-portal/postcss.config.mjs b/developer-portal/postcss.config.mjs new file mode 100644 index 0000000..d0c615b --- /dev/null +++ b/developer-portal/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +export default config diff --git a/developer-portal/tailwind.config.ts b/developer-portal/tailwind.config.ts new file mode 100644 index 0000000..527c226 --- /dev/null +++ b/developer-portal/tailwind.config.ts @@ -0,0 +1,18 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './components/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} + +export default config diff --git a/developer-portal/tsconfig.json b/developer-portal/tsconfig.json new file mode 100644 index 0000000..d81d4ee --- /dev/null +++ b/developer-portal/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b1dd28e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,207 @@ +# ========================================================= +# BreakPilot Compliance — Compliance SDK Platform +# ========================================================= +# Voraussetzung: breakpilot-core muss laufen! +# Start: docker compose up -d +# ========================================================= + +networks: + breakpilot-network: + external: true + name: breakpilot-network + +volumes: + dsms_data: + +services: + + # ========================================================= + # CORE HEALTH CHECK — wartet auf Core-Infrastruktur + # ========================================================= + core-health-check: + image: curlimages/curl:latest + container_name: bp-compliance-core-wait + command: > + sh -c " + echo 'Waiting for Core infrastructure...' + until curl -sf http://bp-core-health:8099/health; do + echo 'Core not ready, waiting 5s...' + sleep 5 + done + echo 'Core is healthy!' + " + restart: "no" + networks: + - breakpilot-network + + # ========================================================= + # FRONTEND + # ========================================================= + admin-compliance: + build: + context: ./admin-compliance + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://macmini:8002} + NEXT_PUBLIC_SDK_URL: ${NEXT_PUBLIC_SDK_URL:-https://macmini:8093} + container_name: bp-compliance-admin + platform: linux/arm64 + expose: + - "3000" + environment: + NODE_ENV: production + BACKEND_URL: http://backend-compliance:8002 + CONSENT_SERVICE_URL: http://bp-core-consent-service:8081 + SDK_URL: http://ai-compliance-sdk:8090 + OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434} + COMPLIANCE_LLM_MODEL: ${COMPLIANCE_LLM_MODEL:-llama3.2} + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + core-health-check: + condition: service_completed_successfully + backend-compliance: + condition: service_started + restart: unless-stopped + networks: + - breakpilot-network + + developer-portal: + build: + context: ./developer-portal + dockerfile: Dockerfile + container_name: bp-compliance-developer-portal + platform: linux/arm64 + expose: + - "3000" + environment: + NODE_ENV: production + restart: unless-stopped + networks: + - breakpilot-network + + # ========================================================= + # BACKEND + # ========================================================= + backend-compliance: + build: + context: ./backend-compliance + dockerfile: Dockerfile + container_name: bp-compliance-backend + platform: linux/arm64 + expose: + - "8002" + environment: + PORT: 8002 + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dcompliance,core,public + JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + ENVIRONMENT: ${ENVIRONMENT:-development} + CONSENT_SERVICE_URL: http://bp-core-consent-service:8081 + VALKEY_URL: redis://bp-core-valkey:6379/0 + SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24} + COMPLIANCE_LLM_PROVIDER: ${COMPLIANCE_LLM_PROVIDER:-ollama} + SELF_HOSTED_LLM_URL: ${SELF_HOSTED_LLM_URL:-http://host.docker.internal:11434} + SELF_HOSTED_LLM_MODEL: ${SELF_HOSTED_LLM_MODEL:-llama3.2} + COMPLIANCE_LLM_MAX_TOKENS: ${COMPLIANCE_LLM_MAX_TOKENS:-4096} + COMPLIANCE_LLM_TEMPERATURE: ${COMPLIANCE_LLM_TEMPERATURE:-0.3} + COMPLIANCE_LLM_TIMEOUT: ${COMPLIANCE_LLM_TIMEOUT:-120} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + SMTP_HOST: ${SMTP_HOST:-bp-core-mailpit} + SMTP_PORT: ${SMTP_PORT:-1025} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot Compliance} + SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-compliance@breakpilot.app} + RAG_SERVICE_URL: http://bp-core-rag-service:8097 + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + core-health-check: + condition: service_completed_successfully + restart: unless-stopped + networks: + - breakpilot-network + + # ========================================================= + # SDK SERVICES + # ========================================================= + ai-compliance-sdk: + build: + context: ./ai-compliance-sdk + dockerfile: Dockerfile + container_name: bp-compliance-ai-sdk + platform: linux/arm64 + environment: + PORT: 8090 + ENVIRONMENT: ${ENVIRONMENT:-development} + DATABASE_URL: postgresql://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db} + JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + LLM_PROVIDER: ${COMPLIANCE_LLM_PROVIDER:-ollama} + LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:-} + OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434} + OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-llama3.2} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_DEFAULT_MODEL: ${ANTHROPIC_DEFAULT_MODEL:-claude-sonnet-4-5-20250929} + PII_REDACTION_ENABLED: ${PII_REDACTION_ENABLED:-true} + PII_REDACTION_LEVEL: ${PII_REDACTION_LEVEL:-standard} + AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365} + AUDIT_LOG_PROMPTS: ${AUDIT_LOG_PROMPTS:-true} + ALLOWED_ORIGINS: "*" + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + core-health-check: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8090/health"] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 + restart: unless-stopped + networks: + - breakpilot-network + + # ========================================================= + # DATA SOVEREIGNTY + # ========================================================= + dsms-node: + build: + context: ./dsms-node + dockerfile: Dockerfile + container_name: bp-compliance-dsms-node + ports: + - "4001:4001" + - "5001:5001" + - "8085:8080" + volumes: + - dsms_data:/data/ipfs + environment: + IPFS_PROFILE: server + healthcheck: + test: ["CMD-SHELL", "ipfs id"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + restart: unless-stopped + networks: + - breakpilot-network + + dsms-gateway: + build: + context: ./dsms-gateway + dockerfile: Dockerfile + container_name: bp-compliance-dsms-gateway + ports: + - "8082:8082" + environment: + IPFS_API_URL: http://dsms-node:5001 + IPFS_GATEWAY_URL: http://dsms-node:8080 + JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + depends_on: + dsms-node: + condition: service_healthy + restart: unless-stopped + networks: + - breakpilot-network diff --git a/dsms-gateway/Dockerfile b/dsms-gateway/Dockerfile new file mode 100644 index 0000000..5ac37dc --- /dev/null +++ b/dsms-gateway/Dockerfile @@ -0,0 +1,32 @@ +# DSMS Gateway - REST API für dezentrales Speichersystem +FROM python:3.11-slim + +LABEL maintainer="BreakPilot " +LABEL description="DSMS Gateway - REST API wrapper for IPFS" + +WORKDIR /app + +# Install curl for healthcheck and dependencies +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY main.py . + +# Environment variables +ENV IPFS_API_URL=http://dsms-node:5001 +ENV IPFS_GATEWAY_URL=http://dsms-node:8080 +ENV PORT=8082 + +# Expose port +EXPOSE 8082 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8082/health || exit 1 + +# Run application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"] diff --git a/dsms-gateway/main.py b/dsms-gateway/main.py new file mode 100644 index 0000000..0a2a390 --- /dev/null +++ b/dsms-gateway/main.py @@ -0,0 +1,467 @@ +""" +DSMS Gateway - REST API für dezentrales Speichersystem +Bietet eine vereinfachte API über IPFS für BreakPilot +""" + +import os +import json +import httpx +import hashlib +from datetime import datetime +from typing import Optional +from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +import io + +app = FastAPI( + title="DSMS Gateway", + description="Dezentrales Daten Speicher System Gateway für BreakPilot", + version="1.0.0" +) + +# CORS Configuration +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8000", "http://backend:8000", "*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Configuration +IPFS_API_URL = os.getenv("IPFS_API_URL", "http://dsms-node:5001") +IPFS_GATEWAY_URL = os.getenv("IPFS_GATEWAY_URL", "http://dsms-node:8080") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") + + +# Models +class DocumentMetadata(BaseModel): + """Metadaten für gespeicherte Dokumente""" + document_type: str # 'legal_document', 'consent_record', 'audit_log' + document_id: Optional[str] = None + version: Optional[str] = None + language: Optional[str] = "de" + created_at: Optional[str] = None + checksum: Optional[str] = None + encrypted: bool = False + + +class StoredDocument(BaseModel): + """Antwort nach erfolgreichem Speichern""" + cid: str # Content Identifier (IPFS Hash) + size: int + metadata: DocumentMetadata + gateway_url: str + timestamp: str + + +class DocumentList(BaseModel): + """Liste der gespeicherten Dokumente""" + documents: list + total: int + + +# Helper Functions +async def verify_token(authorization: Optional[str] = Header(None)) -> dict: + """Verifiziert JWT Token (vereinfacht für MVP)""" + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header fehlt") + + # In Produktion: JWT validieren + # Für MVP: Einfache Token-Prüfung + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Ungültiges Token-Format") + + return {"valid": True} + + +async def ipfs_add(content: bytes, pin: bool = True) -> dict: + """Fügt Inhalt zu IPFS hinzu""" + async with httpx.AsyncClient(timeout=60.0) as client: + files = {"file": ("document", content)} + params = {"pin": str(pin).lower()} + + response = await client.post( + f"{IPFS_API_URL}/api/v0/add", + files=files, + params=params + ) + + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail=f"IPFS Fehler: {response.text}" + ) + + return response.json() + + +async def ipfs_cat(cid: str) -> bytes: + """Liest Inhalt von IPFS""" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/cat", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Dokument nicht gefunden: {cid}" + ) + + return response.content + + +async def ipfs_pin_ls() -> list: + """Listet alle gepinnten Objekte""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/ls", + params={"type": "recursive"} + ) + + if response.status_code != 200: + return [] + + data = response.json() + return list(data.get("Keys", {}).keys()) + + +# API Endpoints +@app.get("/health") +async def health_check(): + """Health Check für DSMS Gateway""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(f"{IPFS_API_URL}/api/v0/id") + ipfs_status = response.status_code == 200 + except Exception: + ipfs_status = False + + return { + "status": "healthy" if ipfs_status else "degraded", + "ipfs_connected": ipfs_status, + "timestamp": datetime.utcnow().isoformat() + } + + +@app.post("/api/v1/documents", response_model=StoredDocument) +async def store_document( + file: UploadFile = File(...), + document_type: str = "legal_document", + document_id: Optional[str] = None, + version: Optional[str] = None, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Speichert ein Dokument im DSMS. + + - **file**: Das zu speichernde Dokument + - **document_type**: Typ des Dokuments (legal_document, consent_record, audit_log) + - **document_id**: Optionale ID des Dokuments + - **version**: Optionale Versionsnummer + - **language**: Sprache (default: de) + """ + content = await file.read() + + # Checksum berechnen + checksum = hashlib.sha256(content).hexdigest() + + # Metadaten erstellen + metadata = DocumentMetadata( + document_type=document_type, + document_id=document_id, + version=version, + language=language, + created_at=datetime.utcnow().isoformat(), + checksum=checksum, + encrypted=False + ) + + # Dokument mit Metadaten als JSON verpacken + package = { + "metadata": metadata.model_dump(), + "content_base64": content.hex(), # Hex-encodiert für JSON + "filename": file.filename + } + + package_bytes = json.dumps(package).encode() + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + size = int(result.get("Size", 0)) + + return StoredDocument( + cid=cid, + size=size, + metadata=metadata, + gateway_url=f"{IPFS_GATEWAY_URL}/ipfs/{cid}", + timestamp=datetime.utcnow().isoformat() + ) + + +@app.get("/api/v1/documents/{cid}") +async def get_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft ein Dokument aus dem DSMS ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + metadata = package.get("metadata", {}) + original_content = bytes.fromhex(package.get("content_base64", "")) + filename = package.get("filename", "document") + + return StreamingResponse( + io.BytesIO(original_content), + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "X-DSMS-Document-Type": metadata.get("document_type", "unknown"), + "X-DSMS-Checksum": metadata.get("checksum", ""), + "X-DSMS-Created-At": metadata.get("created_at", "") + } + ) + except json.JSONDecodeError: + # Wenn es kein DSMS-Paket ist, gib rohen Inhalt zurück + return StreamingResponse( + io.BytesIO(content), + media_type="application/octet-stream" + ) + + +@app.get("/api/v1/documents/{cid}/metadata") +async def get_document_metadata( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft nur die Metadaten eines Dokuments ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + return { + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename"), + "size": len(bytes.fromhex(package.get("content_base64", ""))) + } + except json.JSONDecodeError: + return { + "cid": cid, + "metadata": {}, + "raw_size": len(content) + } + + +@app.get("/api/v1/documents", response_model=DocumentList) +async def list_documents( + _auth: dict = Depends(verify_token) +): + """ + Listet alle gespeicherten Dokumente auf. + """ + cids = await ipfs_pin_ls() + + documents = [] + for cid in cids[:100]: # Limit auf 100 für Performance + try: + content = await ipfs_cat(cid) + package = json.loads(content) + documents.append({ + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename") + }) + except Exception: + # Überspringe nicht-DSMS Objekte + continue + + return DocumentList( + documents=documents, + total=len(documents) + ) + + +@app.delete("/api/v1/documents/{cid}") +async def unpin_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Entfernt ein Dokument aus dem lokalen Pin-Set. + Das Dokument bleibt im Netzwerk, wird aber bei GC entfernt. + + - **cid**: Content Identifier (IPFS Hash) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/rm", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Konnte Pin nicht entfernen: {cid}" + ) + + return { + "status": "unpinned", + "cid": cid, + "message": "Dokument wird bei nächster Garbage Collection entfernt" + } + + +@app.post("/api/v1/legal-documents/archive") +async def archive_legal_document( + document_id: str, + version: str, + content: str, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Archiviert eine rechtliche Dokumentversion dauerhaft. + Speziell für AGB, Datenschutzerklärung, etc. + + - **document_id**: ID des Legal Documents + - **version**: Versionsnummer + - **content**: HTML/Markdown Inhalt + - **language**: Sprache + """ + # Checksum berechnen + content_bytes = content.encode('utf-8') + checksum = hashlib.sha256(content_bytes).hexdigest() + + # Metadaten + metadata = { + "document_type": "legal_document", + "document_id": document_id, + "version": version, + "language": language, + "created_at": datetime.utcnow().isoformat(), + "checksum": checksum, + "content_type": "text/html" + } + + # Paket erstellen + package = { + "metadata": metadata, + "content": content, + "archived_at": datetime.utcnow().isoformat() + } + + package_bytes = json.dumps(package, ensure_ascii=False).encode('utf-8') + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + + return { + "cid": cid, + "document_id": document_id, + "version": version, + "checksum": checksum, + "archived_at": datetime.utcnow().isoformat(), + "verification_url": f"{IPFS_GATEWAY_URL}/ipfs/{cid}" + } + + +@app.get("/api/v1/verify/{cid}") +async def verify_document(cid: str): + """ + Verifiziert die Integrität eines Dokuments. + Öffentlich zugänglich für Audit-Zwecke. + + - **cid**: Content Identifier (IPFS Hash) + """ + try: + content = await ipfs_cat(cid) + package = json.loads(content) + + # Checksum verifizieren + stored_checksum = package.get("metadata", {}).get("checksum") + + if "content_base64" in package: + original_content = bytes.fromhex(package["content_base64"]) + calculated_checksum = hashlib.sha256(original_content).hexdigest() + elif "content" in package: + calculated_checksum = hashlib.sha256( + package["content"].encode('utf-8') + ).hexdigest() + else: + calculated_checksum = None + + integrity_valid = ( + stored_checksum == calculated_checksum + if stored_checksum and calculated_checksum + else None + ) + + return { + "cid": cid, + "exists": True, + "integrity_valid": integrity_valid, + "metadata": package.get("metadata", {}), + "stored_checksum": stored_checksum, + "calculated_checksum": calculated_checksum, + "verified_at": datetime.utcnow().isoformat() + } + except Exception as e: + return { + "cid": cid, + "exists": False, + "error": str(e), + "verified_at": datetime.utcnow().isoformat() + } + + +@app.get("/api/v1/node/info") +async def get_node_info(): + """ + Gibt Informationen über den DSMS Node zurück. + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Node ID + id_response = await client.post(f"{IPFS_API_URL}/api/v0/id") + node_info = id_response.json() if id_response.status_code == 200 else {} + + # Repo Stats + stat_response = await client.post(f"{IPFS_API_URL}/api/v0/repo/stat") + repo_stats = stat_response.json() if stat_response.status_code == 200 else {} + + return { + "node_id": node_info.get("ID"), + "protocol_version": node_info.get("ProtocolVersion"), + "agent_version": node_info.get("AgentVersion"), + "repo_size": repo_stats.get("RepoSize"), + "storage_max": repo_stats.get("StorageMax"), + "num_objects": repo_stats.get("NumObjects"), + "addresses": node_info.get("Addresses", [])[:5] # Erste 5 + } + except Exception as e: + return {"error": str(e)} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8082) diff --git a/dsms-gateway/requirements.txt b/dsms-gateway/requirements.txt new file mode 100644 index 0000000..0c13cca --- /dev/null +++ b/dsms-gateway/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +httpx>=0.25.0 +pydantic>=2.5.0 +python-multipart>=0.0.6 + +# Testing +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/dsms-gateway/test_main.py b/dsms-gateway/test_main.py new file mode 100644 index 0000000..8a40705 --- /dev/null +++ b/dsms-gateway/test_main.py @@ -0,0 +1,612 @@ +""" +Unit Tests für DSMS Gateway +Tests für alle API-Endpoints und Hilfsfunktionen +""" + +import pytest +import hashlib +import json +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient +from httpx import Response + +# Import der App +from main import app, DocumentMetadata, StoredDocument, DocumentList + + +# Test Client +client = TestClient(app) + + +# ==================== Fixtures ==================== + +@pytest.fixture +def valid_auth_header(): + """Gültiger Authorization Header für Tests""" + return {"Authorization": "Bearer test-token-12345"} + + +@pytest.fixture +def sample_document_metadata(): + """Beispiel-Metadaten für Tests""" + return DocumentMetadata( + document_type="legal_document", + document_id="doc-123", + version="1.0", + language="de", + created_at="2024-01-01T00:00:00", + checksum="abc123", + encrypted=False + ) + + +@pytest.fixture +def mock_ipfs_response(): + """Mock-Antwort von IPFS add""" + return { + "Hash": "QmTest1234567890abcdef", + "Size": "1024" + } + + +# ==================== Health Check Tests ==================== + +class TestHealthCheck: + """Tests für den Health Check Endpoint""" + + def test_health_check_ipfs_connected(self): + """Test: Health Check wenn IPFS verbunden ist""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=200) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert "ipfs_connected" in data + assert "timestamp" in data + + def test_health_check_ipfs_disconnected(self): + """Test: Health Check wenn IPFS nicht erreichbar""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.side_effect = Exception("Connection failed") + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "degraded" + assert data["ipfs_connected"] is False + + +# ==================== Authorization Tests ==================== + +class TestAuthorization: + """Tests für die Autorisierung""" + + def test_documents_endpoint_without_auth_returns_401(self): + """Test: Dokument-Endpoint ohne Auth gibt 401 zurück""" + response = client.get("/api/v1/documents") + assert response.status_code == 401 + + def test_documents_endpoint_with_invalid_token_returns_401(self): + """Test: Ungültiges Token-Format gibt 401 zurück""" + response = client.get( + "/api/v1/documents", + headers={"Authorization": "InvalidFormat"} + ) + assert response.status_code == 401 + + def test_documents_endpoint_with_valid_token_format(self, valid_auth_header): + """Test: Gültiges Token-Format wird akzeptiert""" + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = [] + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + + +# ==================== Document Storage Tests ==================== + +class TestDocumentStorage: + """Tests für das Speichern von Dokumenten""" + + def test_store_document_success(self, valid_auth_header, mock_ipfs_response): + """Test: Dokument erfolgreich speichern""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + test_content = b"Test document content" + + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", test_content, "text/plain")}, + data={ + "document_type": "legal_document", + "document_id": "doc-123", + "version": "1.0", + "language": "de" + } + ) + + assert response.status_code == 200 + data = response.json() + assert "cid" in data + assert data["cid"] == "QmTest1234567890abcdef" + assert "metadata" in data + assert "gateway_url" in data + + def test_store_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response): + """Test: Checksum wird korrekt berechnet""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + test_content = b"Test content for checksum" + expected_checksum = hashlib.sha256(test_content).hexdigest() + + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", test_content, "text/plain")} + ) + + assert response.status_code == 200 + data = response.json() + assert data["metadata"]["checksum"] == expected_checksum + + def test_store_document_without_file_returns_422(self, valid_auth_header): + """Test: Fehlende Datei gibt 422 zurück""" + response = client.post( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 422 + + +# ==================== Document Retrieval Tests ==================== + +class TestDocumentRetrieval: + """Tests für das Abrufen von Dokumenten""" + + def test_get_document_success(self, valid_auth_header): + """Test: Dokument erfolgreich abrufen""" + test_content = b"Original content" + package = { + "metadata": { + "document_type": "legal_document", + "checksum": hashlib.sha256(test_content).hexdigest() + }, + "content_base64": test_content.hex(), + "filename": "test.txt" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents/QmTestCid123", + headers=valid_auth_header + ) + + assert response.status_code == 200 + assert response.content == test_content + + def test_get_document_not_found(self, valid_auth_header): + """Test: Nicht existierendes Dokument gibt 404 zurück""" + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + from fastapi import HTTPException + mock_cat.side_effect = HTTPException(status_code=404, detail="Not found") + + response = client.get( + "/api/v1/documents/QmNonExistent", + headers=valid_auth_header + ) + + assert response.status_code == 404 + + def test_get_document_metadata_success(self, valid_auth_header): + """Test: Dokument-Metadaten abrufen""" + test_content = b"Content" + package = { + "metadata": { + "document_type": "legal_document", + "document_id": "doc-123", + "version": "1.0" + }, + "content_base64": test_content.hex(), + "filename": "test.txt" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents/QmTestCid123/metadata", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["cid"] == "QmTestCid123" + assert data["metadata"]["document_type"] == "legal_document" + + +# ==================== Document List Tests ==================== + +class TestDocumentList: + """Tests für das Auflisten von Dokumenten""" + + def test_list_documents_empty(self, valid_auth_header): + """Test: Leere Dokumentenliste""" + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = [] + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["documents"] == [] + assert data["total"] == 0 + + def test_list_documents_with_items(self, valid_auth_header): + """Test: Dokumentenliste mit Einträgen""" + package = { + "metadata": {"document_type": "legal_document"}, + "content_base64": "68656c6c6f", + "filename": "test.txt" + } + + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = ["QmCid1", "QmCid2"] + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + + +# ==================== Document Deletion Tests ==================== + +class TestDocumentDeletion: + """Tests für das Löschen von Dokumenten""" + + def test_unpin_document_success(self, valid_auth_header): + """Test: Dokument erfolgreich unpinnen""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=200) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.delete( + "/api/v1/documents/QmTestCid123", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "unpinned" + assert data["cid"] == "QmTestCid123" + + def test_unpin_document_not_found(self, valid_auth_header): + """Test: Nicht existierendes Dokument unpinnen""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=404) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.delete( + "/api/v1/documents/QmNonExistent", + headers=valid_auth_header + ) + + assert response.status_code == 404 + + +# ==================== Legal Document Archive Tests ==================== + +class TestLegalDocumentArchive: + """Tests für die Legal Document Archivierung""" + + def test_archive_legal_document_success(self, valid_auth_header, mock_ipfs_response): + """Test: Legal Document erfolgreich archivieren""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + response = client.post( + "/api/v1/legal-documents/archive", + headers=valid_auth_header, + params={ + "document_id": "privacy-policy", + "version": "2.0", + "content": "

Datenschutzerklärung

", + "language": "de" + } + ) + + assert response.status_code == 200 + data = response.json() + assert "cid" in data + assert data["document_id"] == "privacy-policy" + assert data["version"] == "2.0" + assert "checksum" in data + assert "archived_at" in data + + def test_archive_legal_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response): + """Test: Checksum für HTML-Inhalt korrekt berechnet""" + content = "

Test Content

" + expected_checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() + + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + response = client.post( + "/api/v1/legal-documents/archive", + headers=valid_auth_header, + params={ + "document_id": "terms", + "version": "1.0", + "content": content + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["checksum"] == expected_checksum + + +# ==================== Document Verification Tests ==================== + +class TestDocumentVerification: + """Tests für die Dokumenten-Verifizierung""" + + def test_verify_document_integrity_valid(self): + """Test: Dokument mit gültiger Integrität""" + content = "Test content" + checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() + + package = { + "metadata": { + "document_type": "legal_document", + "checksum": checksum + }, + "content": content + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is True + assert data["integrity_valid"] is True + assert data["stored_checksum"] == checksum + assert data["calculated_checksum"] == checksum + + def test_verify_document_integrity_invalid(self): + """Test: Dokument mit ungültiger Integrität (manipuliert)""" + package = { + "metadata": { + "document_type": "legal_document", + "checksum": "fake_checksum_12345" + }, + "content": "Actual content" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is True + assert data["integrity_valid"] is False + + def test_verify_document_not_found(self): + """Test: Nicht existierendes Dokument verifizieren""" + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.side_effect = Exception("Not found") + + response = client.get("/api/v1/verify/QmNonExistent") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is False + assert "error" in data + + def test_verify_document_public_access(self): + """Test: Verifizierung ist öffentlich zugänglich (keine Auth)""" + package = { + "metadata": {"checksum": "abc"}, + "content": "test" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + # Kein Authorization Header! + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + + +# ==================== Node Info Tests ==================== + +class TestNodeInfo: + """Tests für Node-Informationen""" + + def test_get_node_info_success(self): + """Test: Node-Informationen abrufen""" + id_response = { + "ID": "QmNodeId12345", + "ProtocolVersion": "ipfs/0.1.0", + "AgentVersion": "kubo/0.24.0", + "Addresses": ["/ip4/127.0.0.1/tcp/4001"] + } + stat_response = { + "RepoSize": 1048576, + "StorageMax": 10737418240, + "NumObjects": 42 + } + + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + + async def mock_post(url, **kwargs): + mock_resp = MagicMock() + if "id" in url: + mock_resp.status_code = 200 + mock_resp.json.return_value = id_response + elif "stat" in url: + mock_resp.status_code = 200 + mock_resp.json.return_value = stat_response + return mock_resp + + mock_instance.post = mock_post + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/api/v1/node/info") + + assert response.status_code == 200 + data = response.json() + assert data["node_id"] == "QmNodeId12345" + assert data["num_objects"] == 42 + + def test_get_node_info_public_access(self): + """Test: Node-Info ist öffentlich zugänglich""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock( + status_code=200, + json=lambda: {} + ) + mock_client.return_value.__aenter__.return_value = mock_instance + + # Kein Authorization Header! + response = client.get("/api/v1/node/info") + + assert response.status_code == 200 + + +# ==================== Model Tests ==================== + +class TestModels: + """Tests für Pydantic Models""" + + def test_document_metadata_defaults(self): + """Test: DocumentMetadata Default-Werte""" + metadata = DocumentMetadata(document_type="test") + + assert metadata.document_type == "test" + assert metadata.document_id is None + assert metadata.version is None + assert metadata.language == "de" + assert metadata.encrypted is False + + def test_document_metadata_all_fields(self): + """Test: DocumentMetadata mit allen Feldern""" + metadata = DocumentMetadata( + document_type="legal_document", + document_id="doc-123", + version="1.0", + language="en", + created_at="2024-01-01T00:00:00", + checksum="abc123", + encrypted=True + ) + + assert metadata.document_type == "legal_document" + assert metadata.document_id == "doc-123" + assert metadata.version == "1.0" + assert metadata.language == "en" + assert metadata.encrypted is True + + def test_stored_document_model(self, sample_document_metadata): + """Test: StoredDocument Model""" + stored = StoredDocument( + cid="QmTest123", + size=1024, + metadata=sample_document_metadata, + gateway_url="http://localhost:8080/ipfs/QmTest123", + timestamp="2024-01-01T00:00:00" + ) + + assert stored.cid == "QmTest123" + assert stored.size == 1024 + assert stored.metadata.document_type == "legal_document" + + def test_document_list_model(self): + """Test: DocumentList Model""" + doc_list = DocumentList( + documents=[{"cid": "Qm1"}, {"cid": "Qm2"}], + total=2 + ) + + assert doc_list.total == 2 + assert len(doc_list.documents) == 2 + + +# ==================== Integration Tests ==================== + +class TestIntegration: + """Integration Tests (erfordern laufenden IPFS Node)""" + + @pytest.mark.skip(reason="Erfordert laufenden IPFS Node") + def test_full_document_lifecycle(self, valid_auth_header): + """Integration Test: Vollständiger Dokument-Lebenszyklus""" + # 1. Dokument speichern + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", b"Test content", "text/plain")} + ) + assert response.status_code == 200 + cid = response.json()["cid"] + + # 2. Dokument abrufen + response = client.get( + f"/api/v1/documents/{cid}", + headers=valid_auth_header + ) + assert response.status_code == 200 + + # 3. Verifizieren + response = client.get(f"/api/v1/verify/{cid}") + assert response.status_code == 200 + assert response.json()["integrity_valid"] is True + + # 4. Unpinnen + response = client.delete( + f"/api/v1/documents/{cid}", + headers=valid_auth_header + ) + assert response.status_code == 200 + + +# ==================== Run Tests ==================== + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/dsms-node/Dockerfile b/dsms-node/Dockerfile new file mode 100644 index 0000000..310076d --- /dev/null +++ b/dsms-node/Dockerfile @@ -0,0 +1,32 @@ +# DSMS Node - Dezentrales Daten Speicher System +# Basiert auf IPFS für BreakPilot PWA + +FROM ipfs/kubo:v0.24.0 + +LABEL maintainer="BreakPilot " +LABEL description="DSMS Node for BreakPilot - Decentralized Storage System" + +# Environment variables +ENV IPFS_PATH=/data/ipfs +ENV IPFS_PROFILE=server + +# Expose ports +# 4001 - Swarm (P2P) +# 5001 - API +# 8080 - Gateway +EXPOSE 4001 +EXPOSE 5001 +EXPOSE 8080 + +# Copy initialization script with correct permissions for ipfs user +USER root +COPY init-dsms.sh /container-init.d/001-init-dsms.sh +RUN chmod 755 /container-init.d/001-init-dsms.sh && chown 1000:users /container-init.d/001-init-dsms.sh +USER ipfs + +# Health check - use ipfs id which works for standalone node +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD ipfs id > /dev/null 2>&1 || exit 1 + +# Default command +CMD ["daemon", "--migrate=true", "--enable-gc"] diff --git a/dsms-node/init-dsms.sh b/dsms-node/init-dsms.sh new file mode 100644 index 0000000..5f85875 --- /dev/null +++ b/dsms-node/init-dsms.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# DSMS Node Initialization Script +# Creates a private IPFS network for BreakPilot + +set -e + +echo "=== DSMS Node Initialization ===" + +# Generate swarm key for private network if not exists +if [ ! -f "$IPFS_PATH/swarm.key" ]; then + echo "Generating private network swarm key..." + + # Use predefined swarm key for BreakPilot private network + # In production, this should be securely generated and shared between nodes + cat > "$IPFS_PATH/swarm.key" << 'EOF' +/key/swarm/psk/1.0.0/ +/base16/ +b3c7e8f4a9d2e1c5f8b7a6d4c3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4 +EOF + + echo "Swarm key created for private network" +fi + +# Configure IPFS for private network +echo "Configuring IPFS for DSMS private network..." + +# Remove default bootstrap nodes (we want a private network) +ipfs bootstrap rm --all 2>/dev/null || true + +# Configure API to listen on all interfaces (for Docker) +ipfs config Addresses.API /ip4/0.0.0.0/tcp/5001 + +# Configure Gateway +ipfs config Addresses.Gateway /ip4/0.0.0.0/tcp/8080 + +# Enable CORS for BreakPilot +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["http://localhost:8000", "http://backend:8000", "*"]' +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["GET", "POST", "PUT", "DELETE"]' +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Headers '["Authorization", "Content-Type", "X-Requested-With"]' + +# Configure for server profile (less aggressive DHT) +ipfs config Routing.Type dht +ipfs config --json Swarm.ConnMgr.LowWater 50 +ipfs config --json Swarm.ConnMgr.HighWater 200 +ipfs config --json Swarm.ConnMgr.GracePeriod '"60s"' + +# Enable garbage collection +ipfs config --json Datastore.GCPeriod '"1h"' +ipfs config --json Datastore.StorageMax '"10GB"' + +# Configure for BreakPilot metadata tagging +ipfs config --json Experimental.FilestoreEnabled true + +echo "=== DSMS Node Configuration Complete ===" +echo "Private Network Key: $(cat $IPFS_PATH/swarm.key | tail -1 | head -c 16)..." +echo "API: http://0.0.0.0:5001" +echo "Gateway: http://0.0.0.0:8080" diff --git a/pca-platform/README.md b/pca-platform/README.md new file mode 100644 index 0000000..38375c4 --- /dev/null +++ b/pca-platform/README.md @@ -0,0 +1,243 @@ +# PCA Platform - Person-Corporate-Agent + +Plattform zur Monetarisierung von KI-Crawler-Zugriffen und Human-vs-Bot-Erkennung. + +## Übersicht + +Die PCA Platform ermöglicht Website-Betreibern: +1. **Bot-Erkennung**: Unterscheidung zwischen Menschen und Bots durch Verhaltensheuristiken +2. **Step-Up-Verification**: WebAuthn oder Proof-of-Work für verdächtige Besucher +3. **Monetarisierung**: KI-Crawler können gegen Micropayment Zugriff erhalten (HTTP 402) + +## Architektur + +``` +┌────────────────────┐ ┌────────────────────┐ ┌──────────────────┐ +│ Website │────▶│ PCA Heuristic │────▶│ Redis │ +│ + PCA SDK │ │ Service │ │ Session Store │ +└────────────────────┘ └────────────────────┘ └──────────────────┘ + │ │ + │ ▼ + │ ┌────────────────────┐ + │ │ Payment Gateway │ (Future) + │ │ HTTP 402 │ + │ └────────────────────┘ + │ + ▼ +┌────────────────────┐ +│ ai-access.json │ +│ Policy Config │ +└────────────────────┘ +``` + +## Komponenten + +### 1. Heuristic Service (Go) +- Port: 8085 +- Berechnet Human-Score basierend auf Verhaltensmetriken +- Verwaltet Step-Up-Verifikation (WebAuthn, PoW) + +### 2. JavaScript SDK +- Sammelt Verhaltensmetriken (Scroll, Mouse, Clicks) +- Sendet Ticks an Backend +- Führt Step-Up bei Bedarf durch + +### 3. ai-access.json +- Policy-Datei für Zugriffsregeln +- Definiert Preise pro Rolle/Bot +- Konfiguriert Schwellenwerte + +## Quick Start + +```bash +cd pca-platform +docker compose up -d +``` + +Services: +- Heuristic Service: http://localhost:8085 +- Demo Site: http://localhost:8087 +- Redis: localhost:6380 + +## API Endpoints + +### Heuristic Service + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/health` | Health Check | +| GET | `/pca/v1/config` | Client Config | +| POST | `/pca/v1/tick` | Metrics empfangen | +| GET | `/pca/v1/evaluate` | Score auswerten | +| GET | `/pca/v1/webauthn-challenge` | WebAuthn Challenge | +| POST | `/pca/v1/webauthn-verify` | WebAuthn verifizieren | +| GET | `/pca/v1/pow-challenge` | PoW Challenge | +| POST | `/pca/v1/pow-verify` | PoW verifizieren | + +### Tick Request + +```json +{ + "session_id": "pca_xxx", + "dwell_ratio": 0.85, + "scroll_depth": 45.0, + "clicks": 5, + "mouse_moves": 120, + "ts": 1702828800000 +} +``` + +### Tick Response + +```json +{ + "session_id": "pca_xxx", + "score": 0.72, + "action": "allow", + "message": "Human behavior detected" +} +``` + +## ai-access.json Konfiguration + +```json +{ + "thresholds": { + "score_pass": 0.7, + "score_challenge": 0.4 + }, + "weights": { + "dwell_ratio": 0.30, + "scroll_score": 0.25, + "pointer_variance": 0.20, + "click_rate": 0.25 + }, + "step_up": { + "methods": ["webauthn", "pow"], + "primary": "webauthn" + }, + "pca_roles": { + "Person": { "access": "allow", "price": null }, + "Agent": { "access": "charge", "price": "0.001 EUR" } + } +} +``` + +## SDK Integration + +### Vanilla JavaScript + +```html + + +``` + +### React + +```jsx +import { useEffect, useState } from 'react'; + +function ProtectedContent() { + const [verified, setVerified] = useState(false); + + useEffect(() => { + PCA.init(config); + PCA.onScoreUpdate(async (score, action) => { + if (score >= 0.7) { + setVerified(true); + } else if (action === 'challenge') { + const success = await PCA.triggerStepUp(); + if (success) setVerified(true); + } + }); + }, []); + + if (!verified) return

Verifying...

; + return
Protected Content
; +} +``` + +## Heuristiken + +| Metrik | Gewicht | Beschreibung | +|--------|---------|--------------| +| `dwell_ratio` | 30% | Sichtbare Verweildauer / Gesamtzeit | +| `scroll_score` | 25% | Maximale Scrolltiefe (0-100%) | +| `pointer_variance` | 20% | Mausbewegungsmuster (Varianz) | +| `click_rate` | 25% | Klicks pro Sekunde + Intervall-Varianz | + +### Score-Interpretation + +| Score | Bedeutung | Aktion | +|-------|-----------|--------| +| ≥0.7 | Wahrscheinlich Mensch | Allow | +| 0.4-0.7 | Unsicher | Optional Challenge | +| <0.4 | Wahrscheinlich Bot | Challenge erforderlich | + +## Step-Up Methoden + +### WebAuthn +- Biometrische Authentifizierung (FaceID, TouchID) +- Hardware Security Keys +- Höchste Sicherheit + +### Proof-of-Work +- Client löst SHA-256 Puzzle +- Kein User-Input nötig +- Bots werden gebremst + +## GDPR Compliance + +Die Plattform ist GDPR-konform: +- ✅ Keine personenbezogenen Daten +- ✅ Keine Cookies +- ✅ IP-Anonymisierung möglich +- ✅ Nur aggregierte Metriken + +## Entwicklung + +### Tests ausführen + +```bash +cd heuristic-service +go test -v ./... +``` + +### Service lokal starten + +```bash +cd heuristic-service +go run ./cmd/server +``` + +## Roadmap + +- [ ] Payment Gateway (HTTP 402) +- [ ] Stablecoin Integration (USDC, EURC) +- [ ] Lightning Network Support +- [ ] Publisher Dashboard +- [ ] Agent SDK für KI-Crawler +- [ ] WordPress Plugin +- [ ] Nginx Module + +## Integration mit BreakPilot + +Die PCA Platform kann in BreakPilot integriert werden: + +1. **Admin-Bereich schützen**: Bot-Schutz für Consent-Management +2. **API monetarisieren**: EduSearch-Daten gegen Zahlung verfügbar machen +3. **Legal Crawler**: Als zahlender Agent auf andere Seiten zugreifen + +## Lizenz + +MIT License - Kommerziell nutzbar diff --git a/pca-platform/ai-access.json b/pca-platform/ai-access.json new file mode 100644 index 0000000..145cfc2 --- /dev/null +++ b/pca-platform/ai-access.json @@ -0,0 +1,82 @@ +{ + "thresholds": { + "score_pass": 0.7, + "score_challenge": 0.4 + }, + "weights": { + "dwell_ratio": 0.30, + "scroll_score": 0.25, + "pointer_variance": 0.20, + "click_rate": 0.25 + }, + "step_up": { + "methods": ["webauthn", "pow"], + "primary": "webauthn", + "webauthn": { + "enabled": true, + "userVerification": "preferred", + "timeout_ms": 60000, + "challenge_endpoint": "/pca/v1/webauthn-challenge" + }, + "pow": { + "enabled": true, + "difficulty": 4, + "max_duration_ms": 5000 + } + }, + "tick": { + "endpoint": "/pca/v1/tick", + "interval_ms": 5000 + }, + "paths": { + "/api/*": { + "min_score": 0.7, + "step_up_method": "webauthn" + }, + "/admin/*": { + "min_score": 0.8, + "step_up_method": "webauthn" + }, + "/public/*": { + "min_score": 0.0, + "step_up_method": null + }, + "default": { + "min_score": 0.4, + "step_up_method": "pow" + } + }, + "pca_roles": { + "Person": { + "description": "Verified human visitor", + "access": "allow", + "price": null + }, + "Corporate": { + "description": "Verified business entity", + "access": "allow", + "price": null + }, + "Agent": { + "description": "AI/Bot agent", + "access": "charge", + "price": { + "amount": "0.001", + "currency": "EUR", + "per": "request" + } + } + }, + "payment": { + "enabled": true, + "methods": ["EURC", "USDC", "Lightning"], + "wallet_address": null, + "min_balance": "0.01" + }, + "compliance": { + "gdpr": true, + "anonymize_ip": true, + "no_cookies": true, + "no_pii": true + } +} diff --git a/pca-platform/demo/index.html b/pca-platform/demo/index.html new file mode 100644 index 0000000..0c3b349 --- /dev/null +++ b/pca-platform/demo/index.html @@ -0,0 +1,444 @@ + + + + + + PCA Platform Demo - Human vs Bot Detection + + + +
+
+

PCA Platform Demo

+

Person - Corporate - Agent | Human vs Bot Detection

+
+ +
+
+
+ 0.00 + Human Score +
+
+

Status: Initializing...

+

Collecting behavioral data...

+
+
+
0%
+
Dwell Time
+
+
+
0%
+
Scroll Depth
+
+
+
0
+
Clicks
+
+
+
0
+
Mouse Moves
+
+
+
+
+
+ +
+

How It Works

+

+ The PCA SDK analyzes your browsing behavior to distinguish humans from bots. + It tracks metrics like scroll depth, mouse movements, click patterns, and dwell time + - all without collecting personal information. +

+

+ Scroll down, move your mouse, and click around + to increase your human score. Once you reach a score of 0.7+, you'll be recognized as human. +

+
+ +
+

Test the SDK

+
+ + + +
+
+ +
+

Protected Content

+

This content is protected and requires a human score of 0.7 or higher to access:

+
+

Content locked. Increase your score to unlock.

+
+
+ +
+

More Content (Scroll Test)

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

+

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+ +
+

Event Log

+
+
[--:--:--] SDK initializing...
+
+
+ +
+

PCA Platform v0.1.0 | GDPR Compliant | No PII Collected

+
+
+ + + + + + diff --git a/pca-platform/docker-compose.yml b/pca-platform/docker-compose.yml new file mode 100644 index 0000000..25f1cab --- /dev/null +++ b/pca-platform/docker-compose.yml @@ -0,0 +1,81 @@ +version: '3.8' + +services: + # Heuristic Service - Human vs Bot detection + heuristic-service: + build: + context: ./heuristic-service + dockerfile: Dockerfile + container_name: pca-heuristic-service + ports: + - "8085:8085" + environment: + - PORT=8085 + - GIN_MODE=release + - CONFIG_PATH=/app/ai-access.json + - REDIS_URL=redis://redis:6379 + volumes: + - ./ai-access.json:/app/ai-access.json:ro + depends_on: + - redis + networks: + - pca-network + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8085/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Payment Gateway - HTTP 402 Handler (future) + # payment-gateway: + # build: + # context: ./payment-gateway + # dockerfile: Dockerfile + # container_name: pca-payment-gateway + # ports: + # - "8086:8086" + # environment: + # - PORT=8086 + # - HEURISTIC_SERVICE_URL=http://heuristic-service:8085 + # depends_on: + # - heuristic-service + # networks: + # - pca-network + + # Redis for session storage + redis: + image: redis:7-alpine + container_name: pca-redis + ports: + - "6380:6379" + volumes: + - pca-redis-data:/data + networks: + - pca-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + + # Demo website to test the SDK + demo-site: + image: nginx:alpine + container_name: pca-demo-site + ports: + - "8087:80" + volumes: + - ./demo:/usr/share/nginx/html:ro + - ./sdk/js/src:/usr/share/nginx/html/sdk:ro + - ./ai-access.json:/usr/share/nginx/html/ai-access.json:ro + depends_on: + - heuristic-service + networks: + - pca-network + +networks: + pca-network: + driver: bridge + +volumes: + pca-redis-data: diff --git a/pca-platform/heuristic-service/Dockerfile b/pca-platform/heuristic-service/Dockerfile new file mode 100644 index 0000000..9f53698 --- /dev/null +++ b/pca-platform/heuristic-service/Dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod ./ + +# Initialize module and download dependencies +RUN go mod tidy || true + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -o /heuristic-service ./cmd/server + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS +RUN apk add --no-cache ca-certificates wget + +# Copy binary from builder +COPY --from=builder /heuristic-service /app/heuristic-service + +# Expose port +EXPOSE 8085 + +# Set environment variables +ENV PORT=8085 +ENV GIN_MODE=release +ENV CONFIG_PATH=/app/ai-access.json + +# Run the service +CMD ["/app/heuristic-service"] diff --git a/pca-platform/heuristic-service/cmd/server/main.go b/pca-platform/heuristic-service/cmd/server/main.go new file mode 100644 index 0000000..35221fb --- /dev/null +++ b/pca-platform/heuristic-service/cmd/server/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "log" + "os" + + "github.com/gin-gonic/gin" + + "github.com/breakpilot/pca-platform/heuristic-service/internal/api" + "github.com/breakpilot/pca-platform/heuristic-service/internal/config" +) + +func main() { + // Load configuration + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "ai-access.json" + } + + cfg, err := config.LoadFromFile(configPath) + if err != nil { + log.Printf("Warning: Could not load config from %s, using defaults: %v", configPath, err) + cfg = config.DefaultConfig() + } + + // Create handler + handler := api.NewHandler(cfg) + + // Start cleanup routine + handler.StartCleanupRoutine() + + // Setup Gin router + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + + r := gin.Default() + + // Enable CORS + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-PCA-Session") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + // Health endpoint + r.GET("/health", handler.HandleHealth) + + // PCA API v1 + v1 := r.Group("/pca/v1") + { + // Configuration endpoint (for client SDK) + v1.GET("/config", handler.HandleGetConfig) + + // Tick endpoint (receives behavioral metrics) + v1.POST("/tick", handler.HandleTick) + + // Evaluation endpoint + v1.GET("/evaluate", handler.HandleEvaluate) + + // WebAuthn step-up + v1.GET("/webauthn-challenge", handler.HandleWebAuthnChallenge) + v1.POST("/webauthn-verify", handler.HandleWebAuthnVerify) + + // Proof-of-Work step-up + v1.GET("/pow-challenge", handler.HandlePoWChallenge) + v1.POST("/pow-verify", handler.HandlePoWVerify) + } + + // Start server + port := cfg.Port + log.Printf("PCA Heuristic Service starting on port %s", port) + log.Printf("Thresholds: pass=%.2f, challenge=%.2f", cfg.Thresholds.ScorePass, cfg.Thresholds.ScoreChallenge) + log.Printf("Step-up methods: %v (primary: %s)", cfg.StepUp.Methods, cfg.StepUp.Primary) + + if err := r.Run(":" + port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/pca-platform/heuristic-service/go.mod b/pca-platform/heuristic-service/go.mod new file mode 100644 index 0000000..b3a96ca --- /dev/null +++ b/pca-platform/heuristic-service/go.mod @@ -0,0 +1,36 @@ +module github.com/breakpilot/pca-platform/heuristic-service + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/google/uuid v1.5.0 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pca-platform/heuristic-service/go.sum b/pca-platform/heuristic-service/go.sum new file mode 100644 index 0000000..391bbee --- /dev/null +++ b/pca-platform/heuristic-service/go.sum @@ -0,0 +1,89 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/pca-platform/heuristic-service/internal/api/handlers.go b/pca-platform/heuristic-service/internal/api/handlers.go new file mode 100644 index 0000000..0582231 --- /dev/null +++ b/pca-platform/heuristic-service/internal/api/handlers.go @@ -0,0 +1,285 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/breakpilot/pca-platform/heuristic-service/internal/config" + "github.com/breakpilot/pca-platform/heuristic-service/internal/heuristics" + "github.com/breakpilot/pca-platform/heuristic-service/internal/stepup" +) + +// Handler holds all API handlers +type Handler struct { + config *config.Config + scorer *heuristics.Scorer + webauthn *stepup.WebAuthnService + pow *stepup.PoWService +} + +// NewHandler creates a new API handler +func NewHandler(cfg *config.Config) *Handler { + return &Handler{ + config: cfg, + scorer: heuristics.NewScorer(cfg), + webauthn: stepup.NewWebAuthnService(&cfg.StepUp.WebAuthn), + pow: stepup.NewPoWService(&cfg.StepUp.PoW), + } +} + +// TickRequest represents metrics sent from client SDK +type TickRequest struct { + SessionID string `json:"session_id"` + Score float64 `json:"score,omitempty"` + DwellRatio float64 `json:"dwell_ratio"` + ScrollDepth float64 `json:"scroll_depth"` + Clicks int `json:"clicks"` + MouseMoves int `json:"mouse_moves"` + KeyStrokes int `json:"key_strokes,omitempty"` + TouchEvents int `json:"touch_events,omitempty"` + MouseVelocities []float64 `json:"mouse_velocities,omitempty"` + ScrollVelocities []float64 `json:"scroll_velocities,omitempty"` + ClickIntervals []float64 `json:"click_intervals,omitempty"` + Timestamp int64 `json:"ts"` +} + +// TickResponse returns the computed score and action +type TickResponse struct { + SessionID string `json:"session_id"` + Score float64 `json:"score"` + Action string `json:"action"` + StepUpMethod string `json:"step_up_method,omitempty"` + Message string `json:"message,omitempty"` +} + +// HandleTick receives tick data from client SDK +func (h *Handler) HandleTick(c *gin.Context) { + var req TickRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Generate session ID if not provided + if req.SessionID == "" { + req.SessionID = uuid.New().String() + } + + // Get or create session + session := h.scorer.GetOrCreateSession(req.SessionID) + + // Update metrics + totalTime := time.Since(session.StartTime).Seconds() + session.VisibleTime = req.DwellRatio * totalTime + session.MaxScrollPercent = req.ScrollDepth / 100.0 // Convert from percentage + session.ClickCount = req.Clicks + session.MouseMoves = req.MouseMoves + session.KeyStrokes = req.KeyStrokes + session.TouchEvents = req.TouchEvents + + if len(req.MouseVelocities) > 0 { + session.MouseVelocities = append(session.MouseVelocities, req.MouseVelocities...) + } + if len(req.ScrollVelocities) > 0 { + session.ScrollVelocities = append(session.ScrollVelocities, req.ScrollVelocities...) + } + if len(req.ClickIntervals) > 0 { + session.ClickIntervals = append(session.ClickIntervals, req.ClickIntervals...) + } + + // Calculate score + score := h.scorer.CalculateScore(req.SessionID) + + // Determine action + var action, stepUpMethod, message string + if score >= h.config.Thresholds.ScorePass { + action = "allow" + message = "Human behavior detected" + } else if score >= h.config.Thresholds.ScoreChallenge { + action = "allow" + message = "Acceptable behavior" + } else { + action = "challenge" + stepUpMethod = h.config.StepUp.Primary + message = "Additional verification required" + } + + c.JSON(http.StatusOK, TickResponse{ + SessionID: req.SessionID, + Score: score, + Action: action, + StepUpMethod: stepUpMethod, + Message: message, + }) +} + +// HandleEvaluate evaluates a session for a specific path +func (h *Handler) HandleEvaluate(c *gin.Context) { + sessionID := c.Query("session_id") + path := c.Query("path") + + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"}) + return + } + + if path == "" { + path = "default" + } + + // TODO: Load path configs from ai-access.json + pathConfigs := map[string]config.PathConfig{} + + result := h.scorer.EvaluateRequest(sessionID, path, pathConfigs) + + c.JSON(http.StatusOK, result) +} + +// HandleWebAuthnChallenge creates a WebAuthn challenge +func (h *Handler) HandleWebAuthnChallenge(c *gin.Context) { + if !h.webauthn.IsEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"}) + return + } + + sessionID := c.Query("session_id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"}) + return + } + + challenge, err := h.webauthn.CreateChallenge(sessionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"}) + return + } + + c.JSON(http.StatusOK, challenge) +} + +// HandleWebAuthnVerify verifies a WebAuthn assertion +func (h *Handler) HandleWebAuthnVerify(c *gin.Context) { + if !h.webauthn.IsEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"}) + return + } + + var req stepup.VerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + verified, err := h.webauthn.VerifyChallenge(&req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "verified": verified, + "session_id": req.SessionID, + }) +} + +// HandlePoWChallenge creates a Proof-of-Work challenge +func (h *Handler) HandlePoWChallenge(c *gin.Context) { + if !h.pow.IsEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"}) + return + } + + sessionID := c.Query("session_id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"}) + return + } + + challenge, err := h.pow.CreateChallenge(sessionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"}) + return + } + + c.JSON(http.StatusOK, challenge) +} + +// HandlePoWVerify verifies a Proof-of-Work solution +func (h *Handler) HandlePoWVerify(c *gin.Context) { + if !h.pow.IsEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"}) + return + } + + var req stepup.PoWVerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + verified, err := h.pow.VerifyChallenge(&req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "verified": verified, + "session_id": req.SessionID, + }) +} + +// HandleGetConfig returns client-safe configuration +func (h *Handler) HandleGetConfig(c *gin.Context) { + // Return only non-sensitive config for client SDK + clientConfig := gin.H{ + "thresholds": h.config.Thresholds, + "weights": h.config.Weights, + "tick": gin.H{ + "endpoint": h.config.Tick.Endpoint, + "interval_ms": h.config.Tick.IntervalMs, + }, + "step_up": gin.H{ + "methods": h.config.StepUp.Methods, + "primary": h.config.StepUp.Primary, + "webauthn": gin.H{ + "enabled": h.config.StepUp.WebAuthn.Enabled, + "userVerification": h.config.StepUp.WebAuthn.UserVerification, + "timeout_ms": h.config.StepUp.WebAuthn.TimeoutMs, + "challenge_endpoint": h.config.StepUp.WebAuthn.ChallengeEndpoint, + }, + "pow": gin.H{ + "enabled": h.config.StepUp.PoW.Enabled, + "difficulty": h.config.StepUp.PoW.Difficulty, + "max_duration_ms": h.config.StepUp.PoW.MaxDurationMs, + }, + }, + "compliance": h.config.Compliance, + } + + c.JSON(http.StatusOK, clientConfig) +} + +// HandleHealth returns service health +func (h *Handler) HandleHealth(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "service": "pca-heuristic-service", + "version": "0.1.0", + }) +} + +// StartCleanupRoutine starts background cleanup +func (h *Handler) StartCleanupRoutine() { + go func() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + h.scorer.CleanupOldSessions(30 * time.Minute) + h.webauthn.CleanupExpiredChallenges() + h.pow.CleanupExpiredChallenges() + } + }() +} diff --git a/pca-platform/heuristic-service/internal/config/config.go b/pca-platform/heuristic-service/internal/config/config.go new file mode 100644 index 0000000..27e4f44 --- /dev/null +++ b/pca-platform/heuristic-service/internal/config/config.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/json" + "os" +) + +// Config holds the heuristic service configuration +type Config struct { + Port string `json:"port"` + RedisURL string `json:"redis_url"` + JWTSecret string `json:"jwt_secret"` + + // Heuristic thresholds + Thresholds ThresholdConfig `json:"thresholds"` + + // Heuristic weights + Weights WeightConfig `json:"weights"` + + // Step-up configuration + StepUp StepUpConfig `json:"step_up"` + + // Tick configuration + Tick TickConfig `json:"tick"` + + // Compliance settings + Compliance ComplianceConfig `json:"compliance"` +} + +// ThresholdConfig defines score thresholds +type ThresholdConfig struct { + ScorePass float64 `json:"score_pass"` // Score to pass without step-up (e.g., 0.7) + ScoreChallenge float64 `json:"score_challenge"` // Score below which step-up is required (e.g., 0.4) +} + +// WeightConfig defines weights for each heuristic +type WeightConfig struct { + DwellRatio float64 `json:"dwell_ratio"` // Weight for dwell time ratio + ScrollScore float64 `json:"scroll_score"` // Weight for scroll depth + PointerVariance float64 `json:"pointer_variance"` // Weight for mouse movement patterns + ClickRate float64 `json:"click_rate"` // Weight for click interactions +} + +// StepUpConfig defines step-up verification methods +type StepUpConfig struct { + Methods []string `json:"methods"` // ["webauthn", "pow"] + Primary string `json:"primary"` // Preferred method + WebAuthn WebAuthnConfig `json:"webauthn"` + PoW PoWConfig `json:"pow"` +} + +// WebAuthnConfig for WebAuthn step-up +type WebAuthnConfig struct { + Enabled bool `json:"enabled"` + UserVerification string `json:"userVerification"` // "preferred", "required", "discouraged" + TimeoutMs int `json:"timeout_ms"` + ChallengeEndpoint string `json:"challenge_endpoint"` +} + +// PoWConfig for Proof-of-Work step-up +type PoWConfig struct { + Enabled bool `json:"enabled"` + Difficulty int `json:"difficulty"` // Number of leading zero bits required + MaxDurationMs int `json:"max_duration_ms"` // Max time for PoW computation +} + +// TickConfig for periodic tick submissions +type TickConfig struct { + Endpoint string `json:"endpoint"` + IntervalMs int `json:"interval_ms"` +} + +// ComplianceConfig for privacy compliance +type ComplianceConfig struct { + GDPR bool `json:"gdpr"` + AnonymizeIP bool `json:"anonymize_ip"` + NoCookies bool `json:"no_cookies"` + NoPII bool `json:"no_pii"` +} + +// PathConfig for path-specific rules +type PathConfig struct { + MinScore float64 `json:"min_score"` + StepUpMethod *string `json:"step_up_method"` // nil means no step-up +} + +// DefaultConfig returns a default configuration +func DefaultConfig() *Config { + return &Config{ + Port: getEnv("PORT", "8085"), + RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"), + JWTSecret: getEnv("JWT_SECRET", "pca-secret-change-me"), + Thresholds: ThresholdConfig{ + ScorePass: 0.7, + ScoreChallenge: 0.4, + }, + Weights: WeightConfig{ + DwellRatio: 0.30, + ScrollScore: 0.25, + PointerVariance: 0.20, + ClickRate: 0.25, + }, + StepUp: StepUpConfig{ + Methods: []string{"webauthn", "pow"}, + Primary: "webauthn", + WebAuthn: WebAuthnConfig{ + Enabled: true, + UserVerification: "preferred", + TimeoutMs: 60000, + ChallengeEndpoint: "/pca/v1/webauthn-challenge", + }, + PoW: PoWConfig{ + Enabled: true, + Difficulty: 4, + MaxDurationMs: 5000, + }, + }, + Tick: TickConfig{ + Endpoint: "/pca/v1/tick", + IntervalMs: 5000, + }, + Compliance: ComplianceConfig{ + GDPR: true, + AnonymizeIP: true, + NoCookies: true, + NoPII: true, + }, + } +} + +// LoadFromFile loads configuration from a JSON file +func LoadFromFile(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return DefaultConfig(), nil // Return default if file not found + } + + config := DefaultConfig() + if err := json.Unmarshal(data, config); err != nil { + return nil, err + } + + return config, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/pca-platform/heuristic-service/internal/heuristics/scorer.go b/pca-platform/heuristic-service/internal/heuristics/scorer.go new file mode 100644 index 0000000..894759c --- /dev/null +++ b/pca-platform/heuristic-service/internal/heuristics/scorer.go @@ -0,0 +1,340 @@ +package heuristics + +import ( + "math" + "sync" + "time" + + "github.com/breakpilot/pca-platform/heuristic-service/internal/config" +) + +// SessionMetrics holds behavioral metrics for a session +type SessionMetrics struct { + SessionID string `json:"session_id"` + StartTime time.Time `json:"start_time"` + VisibleTime float64 `json:"visible_time"` // Seconds visible + LastVisibleTS time.Time `json:"last_visible_ts"` // Last visibility timestamp + MaxScrollPercent float64 `json:"max_scroll_percent"` // 0-1 scroll depth + ClickCount int `json:"click_count"` + MouseMoves int `json:"mouse_moves"` + KeyStrokes int `json:"key_strokes"` + TouchEvents int `json:"touch_events"` + + // Advanced metrics + MouseVelocities []float64 `json:"mouse_velocities,omitempty"` // For variance calculation + ScrollVelocities []float64 `json:"scroll_velocities,omitempty"` // Scroll speed patterns + ClickIntervals []float64 `json:"click_intervals,omitempty"` // Time between clicks + + // Computed score + LastScore float64 `json:"last_score"` + LastScoreTime time.Time `json:"last_score_time"` +} + +// Scorer calculates human-likelihood scores based on behavioral heuristics +type Scorer struct { + config *config.Config + mu sync.RWMutex + sessions map[string]*SessionMetrics +} + +// NewScorer creates a new heuristic scorer +func NewScorer(cfg *config.Config) *Scorer { + return &Scorer{ + config: cfg, + sessions: make(map[string]*SessionMetrics), + } +} + +// GetOrCreateSession retrieves or creates a session +func (s *Scorer) GetOrCreateSession(sessionID string) *SessionMetrics { + s.mu.Lock() + defer s.mu.Unlock() + + if session, exists := s.sessions[sessionID]; exists { + return session + } + + session := &SessionMetrics{ + SessionID: sessionID, + StartTime: time.Now(), + LastVisibleTS: time.Now(), + } + s.sessions[sessionID] = session + return session +} + +// UpdateMetrics updates session metrics from a tick +func (s *Scorer) UpdateMetrics(sessionID string, metrics *SessionMetrics) { + s.mu.Lock() + defer s.mu.Unlock() + + if existing, exists := s.sessions[sessionID]; exists { + // Merge metrics + existing.VisibleTime = metrics.VisibleTime + existing.MaxScrollPercent = metrics.MaxScrollPercent + existing.ClickCount = metrics.ClickCount + existing.MouseMoves = metrics.MouseMoves + existing.KeyStrokes = metrics.KeyStrokes + existing.TouchEvents = metrics.TouchEvents + + if len(metrics.MouseVelocities) > 0 { + existing.MouseVelocities = append(existing.MouseVelocities, metrics.MouseVelocities...) + } + if len(metrics.ScrollVelocities) > 0 { + existing.ScrollVelocities = append(existing.ScrollVelocities, metrics.ScrollVelocities...) + } + if len(metrics.ClickIntervals) > 0 { + existing.ClickIntervals = append(existing.ClickIntervals, metrics.ClickIntervals...) + } + } else { + s.sessions[sessionID] = metrics + } +} + +// CalculateScore computes the human-likelihood score for a session +func (s *Scorer) CalculateScore(sessionID string) float64 { + s.mu.RLock() + session, exists := s.sessions[sessionID] + if !exists { + s.mu.RUnlock() + return 0.0 + } + s.mu.RUnlock() + + weights := s.config.Weights + + // Calculate individual heuristic scores (0-1) + dwellScore := s.calculateDwellScore(session) + scrollScore := s.calculateScrollScore(session) + pointerScore := s.calculatePointerScore(session) + clickScore := s.calculateClickScore(session) + + // Weighted sum + totalScore := dwellScore*weights.DwellRatio + + scrollScore*weights.ScrollScore + + pointerScore*weights.PointerVariance + + clickScore*weights.ClickRate + + // Clamp to [0, 1] + if totalScore > 1.0 { + totalScore = 1.0 + } + if totalScore < 0.0 { + totalScore = 0.0 + } + + // Update session with score + s.mu.Lock() + session.LastScore = totalScore + session.LastScoreTime = time.Now() + s.mu.Unlock() + + return totalScore +} + +// calculateDwellScore: visible time / total time ratio +func (s *Scorer) calculateDwellScore(session *SessionMetrics) float64 { + totalTime := time.Since(session.StartTime).Seconds() + if totalTime <= 0 { + return 0.0 + } + + // Calculate visible time including current period if visible + visibleTime := session.VisibleTime + + ratio := visibleTime / totalTime + if ratio > 1.0 { + ratio = 1.0 + } + + // Apply sigmoid to reward longer dwell times + // A 30+ second dwell with high visibility is very human-like + return sigmoid(ratio, 0.5, 10) +} + +// calculateScrollScore: scroll depth and natural patterns +func (s *Scorer) calculateScrollScore(session *SessionMetrics) float64 { + // Base score from scroll depth + baseScore := session.MaxScrollPercent + if baseScore > 1.0 { + baseScore = 1.0 + } + + // Bonus for natural scroll velocity patterns (humans have variable scroll speeds) + if len(session.ScrollVelocities) > 2 { + variance := calculateVariance(session.ScrollVelocities) + // Too uniform = bot, some variance = human + if variance > 0.01 && variance < 10.0 { + baseScore *= 1.2 // Boost for natural variance + } + } + + if baseScore > 1.0 { + baseScore = 1.0 + } + + return baseScore +} + +// calculatePointerScore: mouse movement patterns +func (s *Scorer) calculatePointerScore(session *SessionMetrics) float64 { + // Binary: has mouse activity at all + if session.MouseMoves == 0 && session.TouchEvents == 0 { + return 0.0 + } + + baseScore := 0.5 // Some activity + + // Humans have variable mouse velocities + if len(session.MouseVelocities) > 5 { + variance := calculateVariance(session.MouseVelocities) + // Bots often have either very uniform or very erratic movement + if variance > 0.1 && variance < 100.0 { + baseScore = 0.9 // Natural variance pattern + } else if variance <= 0.1 { + baseScore = 0.3 // Too uniform - suspicious + } else { + baseScore = 0.4 // Too erratic - also suspicious + } + } + + // Boost for touch events (mobile users) + if session.TouchEvents > 0 { + baseScore += 0.2 + } + + if baseScore > 1.0 { + baseScore = 1.0 + } + + return baseScore +} + +// calculateClickScore: click patterns +func (s *Scorer) calculateClickScore(session *SessionMetrics) float64 { + if session.ClickCount == 0 { + return 0.0 + } + + totalTime := time.Since(session.StartTime).Seconds() + if totalTime <= 0 { + return 0.0 + } + + // Clicks per second + clickRate := float64(session.ClickCount) / totalTime + + // Natural click rate is 0.1-2 clicks per second + // Too fast = bot, none = no interaction + var baseScore float64 + if clickRate > 0.05 && clickRate < 3.0 { + baseScore = 0.8 + } else if clickRate >= 3.0 { + baseScore = 0.2 // Suspiciously fast clicking + } else { + baseScore = 0.4 + } + + // Check for natural intervals between clicks + if len(session.ClickIntervals) > 2 { + variance := calculateVariance(session.ClickIntervals) + // Natural human timing has variance + if variance > 0.01 { + baseScore += 0.2 + } + } + + if baseScore > 1.0 { + baseScore = 1.0 + } + + return baseScore +} + +// EvaluateRequest determines action based on score +func (s *Scorer) EvaluateRequest(sessionID string, path string, pathConfigs map[string]config.PathConfig) *EvaluationResult { + score := s.CalculateScore(sessionID) + + // Get path-specific config or use defaults + minScore := s.config.Thresholds.ScoreChallenge + var stepUpMethod *string + + if cfg, exists := pathConfigs[path]; exists { + minScore = cfg.MinScore + stepUpMethod = cfg.StepUpMethod + } + + result := &EvaluationResult{ + SessionID: sessionID, + Score: score, + MinScore: minScore, + Action: "allow", + } + + if score >= s.config.Thresholds.ScorePass { + result.Action = "allow" + } else if score >= minScore { + result.Action = "allow" // In gray zone but above minimum + } else { + result.Action = "challenge" + if stepUpMethod != nil { + result.StepUpMethod = *stepUpMethod + } else { + result.StepUpMethod = s.config.StepUp.Primary + } + } + + return result +} + +// EvaluationResult contains the score evaluation outcome +type EvaluationResult struct { + SessionID string `json:"session_id"` + Score float64 `json:"score"` + MinScore float64 `json:"min_score"` + Action string `json:"action"` // "allow", "challenge", "block" + StepUpMethod string `json:"step_up_method,omitempty"` +} + +// CleanupOldSessions removes sessions older than maxAge +func (s *Scorer) CleanupOldSessions(maxAge time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for id, session := range s.sessions { + if now.Sub(session.StartTime) > maxAge { + delete(s.sessions, id) + } + } +} + +// Helper functions + +func calculateVariance(values []float64) float64 { + if len(values) < 2 { + return 0.0 + } + + // Calculate mean + var sum float64 + for _, v := range values { + sum += v + } + mean := sum / float64(len(values)) + + // Calculate variance + var variance float64 + for _, v := range values { + diff := v - mean + variance += diff * diff + } + variance /= float64(len(values) - 1) + + return variance +} + +// sigmoid applies a sigmoid transformation for smoother score curves +func sigmoid(x, midpoint, steepness float64) float64 { + return 1.0 / (1.0 + math.Exp(-steepness*(x-midpoint))) +} diff --git a/pca-platform/heuristic-service/internal/heuristics/scorer_test.go b/pca-platform/heuristic-service/internal/heuristics/scorer_test.go new file mode 100644 index 0000000..462ec61 --- /dev/null +++ b/pca-platform/heuristic-service/internal/heuristics/scorer_test.go @@ -0,0 +1,250 @@ +package heuristics + +import ( + "testing" + "time" + + "github.com/breakpilot/pca-platform/heuristic-service/internal/config" +) + +func TestNewScorer(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + if scorer == nil { + t.Fatal("Expected non-nil scorer") + } + if scorer.config == nil { + t.Error("Expected config to be set") + } + if scorer.sessions == nil { + t.Error("Expected sessions map to be initialized") + } +} + +func TestGetOrCreateSession(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + // First call should create session + session1 := scorer.GetOrCreateSession("test-session-1") + if session1 == nil { + t.Fatal("Expected non-nil session") + } + if session1.SessionID != "test-session-1" { + t.Errorf("Expected session ID 'test-session-1', got '%s'", session1.SessionID) + } + + // Second call should return same session + session2 := scorer.GetOrCreateSession("test-session-1") + if session1 != session2 { + t.Error("Expected same session instance on second call") + } + + // Different ID should create new session + session3 := scorer.GetOrCreateSession("test-session-2") + if session1 == session3 { + t.Error("Expected different session for different ID") + } +} + +func TestCalculateScore_NewSession(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + // New session with no activity should have low score + scorer.GetOrCreateSession("test-new") + score := scorer.CalculateScore("test-new") + + if score < 0 || score > 1 { + t.Errorf("Expected score between 0 and 1, got %f", score) + } +} + +func TestCalculateScore_HighActivity(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + session := scorer.GetOrCreateSession("test-active") + session.StartTime = time.Now().Add(-30 * time.Second) + session.VisibleTime = 28.0 // High visibility + session.MaxScrollPercent = 0.8 + session.ClickCount = 10 + session.MouseMoves = 100 + session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90} + session.ClickIntervals = []float64{1.5, 2.0, 1.2, 0.8} + + score := scorer.CalculateScore("test-active") + + // Active session should have higher score + if score < 0.5 { + t.Errorf("Expected score > 0.5 for active session, got %f", score) + } +} + +func TestCalculateScore_BotLikeActivity(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + session := scorer.GetOrCreateSession("test-bot") + session.StartTime = time.Now().Add(-5 * time.Second) + session.VisibleTime = 1.0 // Very short + session.MaxScrollPercent = 0.0 + session.ClickCount = 0 + session.MouseMoves = 0 + + score := scorer.CalculateScore("test-bot") + + // Bot-like session should have very low score + if score > 0.3 { + t.Errorf("Expected score < 0.3 for bot-like session, got %f", score) + } +} + +func TestCalculateScore_UniformMouseMovement(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + session := scorer.GetOrCreateSession("test-uniform") + session.StartTime = time.Now().Add(-20 * time.Second) + session.VisibleTime = 18.0 + session.MouseMoves = 50 + // Very uniform velocities (suspicious) + session.MouseVelocities = []float64{100, 100, 100, 100, 100, 100, 100, 100} + + score := scorer.CalculateScore("test-uniform") + + // Uniform movement should result in lower pointer score + if score > 0.7 { + t.Errorf("Expected score < 0.7 for uniform mouse movement, got %f", score) + } +} + +func TestEvaluateRequest(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + // High score session + session := scorer.GetOrCreateSession("test-evaluate") + session.StartTime = time.Now().Add(-60 * time.Second) + session.VisibleTime = 55.0 + session.MaxScrollPercent = 0.9 + session.ClickCount = 15 + session.MouseMoves = 200 + session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90, 110} + + result := scorer.EvaluateRequest("test-evaluate", "/default", nil) + + if result.SessionID != "test-evaluate" { + t.Errorf("Expected session ID 'test-evaluate', got '%s'", result.SessionID) + } + if result.Action != "allow" && result.Score >= cfg.Thresholds.ScorePass { + t.Errorf("Expected 'allow' action for high score, got '%s'", result.Action) + } +} + +func TestEvaluateRequest_Challenge(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + // Low score session + scorer.GetOrCreateSession("test-challenge") + + result := scorer.EvaluateRequest("test-challenge", "/api", nil) + + if result.Action != "challenge" { + t.Errorf("Expected 'challenge' action for new session, got '%s'", result.Action) + } + if result.StepUpMethod == "" { + t.Error("Expected step-up method to be set for challenge") + } +} + +func TestCleanupOldSessions(t *testing.T) { + cfg := config.DefaultConfig() + scorer := NewScorer(cfg) + + // Create some sessions + scorer.GetOrCreateSession("session-new") + + oldSession := scorer.GetOrCreateSession("session-old") + oldSession.StartTime = time.Now().Add(-2 * time.Hour) + + // Verify both exist + if len(scorer.sessions) != 2 { + t.Errorf("Expected 2 sessions, got %d", len(scorer.sessions)) + } + + // Cleanup with 1 hour max age + scorer.CleanupOldSessions(1 * time.Hour) + + // Old session should be removed + if len(scorer.sessions) != 1 { + t.Errorf("Expected 1 session after cleanup, got %d", len(scorer.sessions)) + } + + if _, exists := scorer.sessions["session-old"]; exists { + t.Error("Expected old session to be cleaned up") + } +} + +func TestCalculateVariance(t *testing.T) { + tests := []struct { + name string + values []float64 + expected float64 + }{ + { + name: "empty", + values: []float64{}, + expected: 0.0, + }, + { + name: "single value", + values: []float64{5.0}, + expected: 0.0, + }, + { + name: "uniform values", + values: []float64{5.0, 5.0, 5.0, 5.0}, + expected: 0.0, + }, + { + name: "varied values", + values: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, + expected: 2.5, // Variance of [1,2,3,4,5] + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateVariance(tt.values) + if tt.expected == 0.0 && result != 0.0 { + t.Errorf("Expected 0 variance, got %f", result) + } + if tt.expected != 0.0 && (result < tt.expected-0.1 || result > tt.expected+0.1) { + t.Errorf("Expected variance ~%f, got %f", tt.expected, result) + } + }) + } +} + +func TestSigmoid(t *testing.T) { + // Test sigmoid at midpoint + result := sigmoid(0.5, 0.5, 10) + if result < 0.49 || result > 0.51 { + t.Errorf("Expected sigmoid(0.5, 0.5, 10) ~ 0.5, got %f", result) + } + + // Test sigmoid well above midpoint + result = sigmoid(1.0, 0.5, 10) + if result < 0.9 { + t.Errorf("Expected sigmoid(1.0, 0.5, 10) > 0.9, got %f", result) + } + + // Test sigmoid well below midpoint + result = sigmoid(0.0, 0.5, 10) + if result > 0.1 { + t.Errorf("Expected sigmoid(0.0, 0.5, 10) < 0.1, got %f", result) + } +} diff --git a/pca-platform/heuristic-service/internal/stepup/pow.go b/pca-platform/heuristic-service/internal/stepup/pow.go new file mode 100644 index 0000000..12143ba --- /dev/null +++ b/pca-platform/heuristic-service/internal/stepup/pow.go @@ -0,0 +1,180 @@ +package stepup + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" + + "github.com/breakpilot/pca-platform/heuristic-service/internal/config" +) + +// PoWService handles Proof-of-Work challenges +type PoWService struct { + config *config.PoWConfig + challenges map[string]*PoWChallenge + mu sync.RWMutex +} + +// PoWChallenge represents a Proof-of-Work challenge +type PoWChallenge struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Challenge string `json:"challenge"` + Difficulty int `json:"difficulty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Solved bool `json:"solved"` +} + +// PoWChallengeResponse is sent to the client +type PoWChallengeResponse struct { + ChallengeID string `json:"challenge_id"` + Challenge string `json:"challenge"` + Difficulty int `json:"difficulty"` + MaxTimeMs int `json:"max_time_ms"` + Hint string `json:"hint"` +} + +// PoWVerifyRequest for verifying a solved challenge +type PoWVerifyRequest struct { + SessionID string `json:"session_id"` + ChallengeID string `json:"challenge_id"` + Challenge string `json:"challenge"` + Nonce int64 `json:"nonce"` +} + +// NewPoWService creates a new Proof-of-Work service +func NewPoWService(cfg *config.PoWConfig) *PoWService { + return &PoWService{ + config: cfg, + challenges: make(map[string]*PoWChallenge), + } +} + +// CreateChallenge generates a new PoW challenge +func (s *PoWService) CreateChallenge(sessionID string) (*PoWChallengeResponse, error) { + // Generate random challenge + challengeBytes := make([]byte, 16) + if _, err := rand.Read(challengeBytes); err != nil { + return nil, err + } + challengeStr := hex.EncodeToString(challengeBytes) + + // Generate challenge ID + idBytes := make([]byte, 8) + rand.Read(idBytes) + challengeID := hex.EncodeToString(idBytes) + + // Create challenge + challenge := &PoWChallenge{ + ID: challengeID, + SessionID: sessionID, + Challenge: challengeStr, + Difficulty: s.config.Difficulty, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(s.config.MaxDurationMs*2) * time.Millisecond), + Solved: false, + } + + // Store challenge + s.mu.Lock() + s.challenges[challengeID] = challenge + s.mu.Unlock() + + // Build response + prefix := strings.Repeat("0", s.config.Difficulty) + response := &PoWChallengeResponse{ + ChallengeID: challengeID, + Challenge: challengeStr, + Difficulty: s.config.Difficulty, + MaxTimeMs: s.config.MaxDurationMs, + Hint: fmt.Sprintf("Find nonce where SHA256(challenge + nonce) starts with '%s'", prefix), + } + + return response, nil +} + +// VerifyChallenge verifies a PoW solution +func (s *PoWService) VerifyChallenge(req *PoWVerifyRequest) (bool, error) { + s.mu.RLock() + challenge, exists := s.challenges[req.ChallengeID] + s.mu.RUnlock() + + if !exists { + return false, nil + } + + // Check expiration + if time.Now().After(challenge.ExpiresAt) { + s.mu.Lock() + delete(s.challenges, req.ChallengeID) + s.mu.Unlock() + return false, nil + } + + // Check session match + if challenge.SessionID != req.SessionID { + return false, nil + } + + // Check challenge string match + if challenge.Challenge != req.Challenge { + return false, nil + } + + // Verify the proof of work + input := fmt.Sprintf("%s%d", req.Challenge, req.Nonce) + hash := sha256.Sum256([]byte(input)) + hashHex := hex.EncodeToString(hash[:]) + + // Check if hash has required number of leading zeros + prefix := strings.Repeat("0", challenge.Difficulty) + if !strings.HasPrefix(hashHex, prefix) { + return false, nil + } + + // Mark as solved + s.mu.Lock() + challenge.Solved = true + s.mu.Unlock() + + return true, nil +} + +// VerifyProof is a standalone verification without stored challenge +// Useful for quick verification +func (s *PoWService) VerifyProof(challenge string, nonce int64, difficulty int) bool { + input := fmt.Sprintf("%s%d", challenge, nonce) + hash := sha256.Sum256([]byte(input)) + hashHex := hex.EncodeToString(hash[:]) + + prefix := strings.Repeat("0", difficulty) + return strings.HasPrefix(hashHex, prefix) +} + +// CleanupExpiredChallenges removes expired challenges +func (s *PoWService) CleanupExpiredChallenges() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for id, challenge := range s.challenges { + if now.After(challenge.ExpiresAt) { + delete(s.challenges, id) + } + } +} + +// IsEnabled returns whether PoW is enabled +func (s *PoWService) IsEnabled() bool { + return s.config.Enabled +} + +// GetDifficulty returns configured difficulty +func (s *PoWService) GetDifficulty() int { + return s.config.Difficulty +} diff --git a/pca-platform/heuristic-service/internal/stepup/pow_test.go b/pca-platform/heuristic-service/internal/stepup/pow_test.go new file mode 100644 index 0000000..4dc3e41 --- /dev/null +++ b/pca-platform/heuristic-service/internal/stepup/pow_test.go @@ -0,0 +1,235 @@ +package stepup + +import ( + "testing" + + "github.com/breakpilot/pca-platform/heuristic-service/internal/config" +) + +func TestNewPoWService(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 4, + MaxDurationMs: 5000, + } + + service := NewPoWService(cfg) + + if service == nil { + t.Fatal("Expected non-nil service") + } + if !service.IsEnabled() { + t.Error("Expected service to be enabled") + } + if service.GetDifficulty() != 4 { + t.Errorf("Expected difficulty 4, got %d", service.GetDifficulty()) + } +} + +func TestCreateChallenge(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 4, + MaxDurationMs: 5000, + } + + service := NewPoWService(cfg) + response, err := service.CreateChallenge("test-session") + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if response == nil { + t.Fatal("Expected non-nil response") + } + if response.Challenge == "" { + t.Error("Expected non-empty challenge") + } + if response.ChallengeID == "" { + t.Error("Expected non-empty challenge ID") + } + if response.Difficulty != 4 { + t.Errorf("Expected difficulty 4, got %d", response.Difficulty) + } + if response.MaxTimeMs != 5000 { + t.Errorf("Expected max time 5000, got %d", response.MaxTimeMs) + } +} + +func TestVerifyProof_Valid(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 2, // Low difficulty for fast testing + MaxDurationMs: 5000, + } + + service := NewPoWService(cfg) + + // Find a valid nonce for a known challenge + challenge := "test-challenge-123" + var validNonce int64 = -1 + + // Brute force to find valid nonce (with low difficulty) + for nonce := int64(0); nonce < 10000; nonce++ { + if service.VerifyProof(challenge, nonce, 2) { + validNonce = nonce + break + } + } + + if validNonce == -1 { + t.Skip("Could not find valid nonce in reasonable time") + } + + // Verify the found nonce + if !service.VerifyProof(challenge, validNonce, 2) { + t.Errorf("Expected valid proof for nonce %d", validNonce) + } +} + +func TestVerifyProof_Invalid(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 4, + MaxDurationMs: 5000, + } + + service := NewPoWService(cfg) + + // Nonce 0 is very unlikely to be valid for difficulty 4 + valid := service.VerifyProof("random-challenge", 0, 4) + + if valid { + t.Error("Expected invalid proof for nonce 0") + } +} + +func TestVerifyChallenge_ValidFlow(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 2, + MaxDurationMs: 10000, + } + + service := NewPoWService(cfg) + + // Create challenge + response, err := service.CreateChallenge("test-session") + if err != nil { + t.Fatalf("Failed to create challenge: %v", err) + } + + // Find valid nonce + var validNonce int64 = -1 + for nonce := int64(0); nonce < 100000; nonce++ { + if service.VerifyProof(response.Challenge, nonce, 2) { + validNonce = nonce + break + } + } + + if validNonce == -1 { + t.Skip("Could not find valid nonce") + } + + // Verify challenge + req := &PoWVerifyRequest{ + SessionID: "test-session", + ChallengeID: response.ChallengeID, + Challenge: response.Challenge, + Nonce: validNonce, + } + + verified, err := service.VerifyChallenge(req) + if err != nil { + t.Fatalf("Verification error: %v", err) + } + if !verified { + t.Error("Expected verification to succeed") + } +} + +func TestVerifyChallenge_WrongSession(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 2, + MaxDurationMs: 5000, + } + + service := NewPoWService(cfg) + + // Create challenge for session A + response, _ := service.CreateChallenge("session-a") + + // Try to verify with session B + req := &PoWVerifyRequest{ + SessionID: "session-b", + ChallengeID: response.ChallengeID, + Challenge: response.Challenge, + Nonce: 0, + } + + verified, _ := service.VerifyChallenge(req) + if verified { + t.Error("Expected verification to fail for wrong session") + } +} + +func TestVerifyChallenge_NonexistentChallenge(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 2, + MaxDurationMs: 5000, + } + + service := NewPoWService(cfg) + + req := &PoWVerifyRequest{ + SessionID: "test-session", + ChallengeID: "nonexistent-challenge", + Challenge: "test", + Nonce: 0, + } + + verified, _ := service.VerifyChallenge(req) + if verified { + t.Error("Expected verification to fail for nonexistent challenge") + } +} + +func TestCleanupExpiredChallenges(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: true, + Difficulty: 2, + MaxDurationMs: 1, // Very short for testing + } + + service := NewPoWService(cfg) + + // Create challenge + service.CreateChallenge("test-session") + + if len(service.challenges) != 1 { + t.Errorf("Expected 1 challenge, got %d", len(service.challenges)) + } + + // Wait for expiration + // Note: In real test, we'd mock time or set ExpiresAt in the past + + // For now, just verify cleanup doesn't crash + service.CleanupExpiredChallenges() +} + +func TestIsEnabled(t *testing.T) { + cfg := &config.PoWConfig{ + Enabled: false, + Difficulty: 4, + MaxDurationMs: 5000, + } + + service := NewPoWService(cfg) + + if service.IsEnabled() { + t.Error("Expected service to be disabled") + } +} diff --git a/pca-platform/heuristic-service/internal/stepup/webauthn.go b/pca-platform/heuristic-service/internal/stepup/webauthn.go new file mode 100644 index 0000000..d3b7c9d --- /dev/null +++ b/pca-platform/heuristic-service/internal/stepup/webauthn.go @@ -0,0 +1,172 @@ +package stepup + +import ( + "crypto/rand" + "encoding/base64" + "sync" + "time" + + "github.com/breakpilot/pca-platform/heuristic-service/internal/config" +) + +// WebAuthnService handles WebAuthn challenges and verification +type WebAuthnService struct { + config *config.WebAuthnConfig + challenges map[string]*Challenge + mu sync.RWMutex +} + +// Challenge represents a WebAuthn challenge +type Challenge struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Challenge string `json:"challenge"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Verified bool `json:"verified"` +} + +// ChallengeRequest is the client-side challenge request format +type ChallengeRequest struct { + SessionID string `json:"session_id"` +} + +// ChallengeResponse is the WebAuthn public key request options +type ChallengeResponse struct { + PublicKey PublicKeyCredentialRequestOptions `json:"publicKey"` +} + +// PublicKeyCredentialRequestOptions mirrors the WebAuthn API structure +type PublicKeyCredentialRequestOptions struct { + Challenge string `json:"challenge"` + Timeout int `json:"timeout"` + RpID string `json:"rpId,omitempty"` + UserVerification string `json:"userVerification"` + AllowCredentials []PublicKeyCredentialDescriptor `json:"allowCredentials,omitempty"` +} + +// PublicKeyCredentialDescriptor for allowed credentials +type PublicKeyCredentialDescriptor struct { + Type string `json:"type"` + ID string `json:"id"` + Transports []string `json:"transports,omitempty"` +} + +// VerifyRequest for client verification response +type VerifyRequest struct { + SessionID string `json:"session_id"` + ChallengeID string `json:"challenge_id"` + Credential map[string]interface{} `json:"credential"` +} + +// NewWebAuthnService creates a new WebAuthn service +func NewWebAuthnService(cfg *config.WebAuthnConfig) *WebAuthnService { + return &WebAuthnService{ + config: cfg, + challenges: make(map[string]*Challenge), + } +} + +// CreateChallenge generates a new WebAuthn challenge for a session +func (s *WebAuthnService) CreateChallenge(sessionID string) (*ChallengeResponse, error) { + // Generate random challenge bytes + challengeBytes := make([]byte, 32) + if _, err := rand.Read(challengeBytes); err != nil { + return nil, err + } + challengeStr := base64.RawURLEncoding.EncodeToString(challengeBytes) + + // Generate challenge ID + idBytes := make([]byte, 16) + rand.Read(idBytes) + challengeID := base64.RawURLEncoding.EncodeToString(idBytes) + + // Create challenge + challenge := &Challenge{ + ID: challengeID, + SessionID: sessionID, + Challenge: challengeStr, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(s.config.TimeoutMs) * time.Millisecond), + Verified: false, + } + + // Store challenge + s.mu.Lock() + s.challenges[challengeID] = challenge + s.mu.Unlock() + + // Build response + response := &ChallengeResponse{ + PublicKey: PublicKeyCredentialRequestOptions{ + Challenge: challengeStr, + Timeout: s.config.TimeoutMs, + UserVerification: s.config.UserVerification, + // In production, you'd include allowed credentials from user registration + AllowCredentials: []PublicKeyCredentialDescriptor{}, + }, + } + + return response, nil +} + +// VerifyChallenge verifies a WebAuthn assertion response +func (s *WebAuthnService) VerifyChallenge(req *VerifyRequest) (bool, error) { + s.mu.RLock() + challenge, exists := s.challenges[req.ChallengeID] + s.mu.RUnlock() + + if !exists { + return false, nil + } + + // Check expiration + if time.Now().After(challenge.ExpiresAt) { + s.mu.Lock() + delete(s.challenges, req.ChallengeID) + s.mu.Unlock() + return false, nil + } + + // Check session match + if challenge.SessionID != req.SessionID { + return false, nil + } + + // In production, you would: + // 1. Parse the credential response + // 2. Verify the signature against stored public key + // 3. Verify the challenge matches + // 4. Check the origin + // For MVP, we accept any valid-looking response + + // Verify credential structure exists + if req.Credential == nil { + return false, nil + } + + // Mark as verified + s.mu.Lock() + challenge.Verified = true + s.mu.Unlock() + + return true, nil +} + +// CleanupExpiredChallenges removes expired challenges +func (s *WebAuthnService) CleanupExpiredChallenges() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for id, challenge := range s.challenges { + if now.After(challenge.ExpiresAt) { + delete(s.challenges, id) + } + } +} + +// IsEnabled returns whether WebAuthn is enabled +func (s *WebAuthnService) IsEnabled() bool { + return s.config.Enabled +} diff --git a/pca-platform/sdk/js/src/pca-sdk.js b/pca-platform/sdk/js/src/pca-sdk.js new file mode 100644 index 0000000..3f8b02f --- /dev/null +++ b/pca-platform/sdk/js/src/pca-sdk.js @@ -0,0 +1,473 @@ +/** + * PCA SDK - Person-Corporate-Agent Human Detection SDK + * + * Collects behavioral metrics to distinguish humans from bots + * and handles step-up verification (WebAuthn, PoW) when needed. + * + * GDPR/Privacy compliant: No PII collected, only aggregated behavior metrics. + */ + +const PCA = (() => { + // Internal state + let config = null; + let sessionId = null; + let metrics = { + startTime: Date.now(), + visibleTime: 0, + lastVisibleTS: Date.now(), + maxScrollPercent: 0, + clickCount: 0, + mouseMoves: 0, + keyStrokes: 0, + touchEvents: 0, + mouseVelocities: [], + scrollVelocities: [], + clickIntervals: [], + lastClickTime: 0, + lastMousePos: null, + lastMouseTime: 0, + lastScrollPos: 0, + lastScrollTime: 0 + }; + let currentScore = 0; + let tickTimer = null; + let isInitialized = false; + let scoreCallbacks = []; + + // Generate unique session ID + function generateSessionId() { + return 'pca_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); + } + + // Calculate score based on current metrics + function evaluateScore() { + const now = Date.now(); + const totalTime = (now - metrics.startTime) / 1000; + + // Update visible time if page is visible + if (!document.hidden) { + metrics.visibleTime += (now - metrics.lastVisibleTS) / 1000; + metrics.lastVisibleTS = now; + } + + // Heuristic 1: Dwell ratio (visible time / total time) + let dwellRatio = totalTime > 0 ? (metrics.visibleTime / totalTime) : 0; + if (dwellRatio > 1) dwellRatio = 1; + + // Heuristic 2: Scroll score (max scroll depth 0-1) + let scrollScore = metrics.maxScrollPercent; + if (scrollScore > 1) scrollScore = 1; + + // Heuristic 3: Pointer variance (mouse/touch activity) + let pointerScore = 0; + if (metrics.mouseMoves > 0 || metrics.touchEvents > 0) { + pointerScore = 0.5; + // Check for natural mouse velocity variance + if (metrics.mouseVelocities.length > 5) { + const variance = calculateVariance(metrics.mouseVelocities); + if (variance > 0.1 && variance < 100.0) { + pointerScore = 0.9; // Natural variance + } else if (variance <= 0.1) { + pointerScore = 0.3; // Too uniform - suspicious + } + } + if (metrics.touchEvents > 0) pointerScore += 0.2; + if (pointerScore > 1) pointerScore = 1; + } + + // Heuristic 4: Click rate + let clickScore = 0; + if (metrics.clickCount > 0 && totalTime > 0) { + const clickRate = metrics.clickCount / totalTime; + if (clickRate > 0.05 && clickRate < 3.0) { + clickScore = 0.8; + } else if (clickRate >= 3.0) { + clickScore = 0.2; // Too fast + } else { + clickScore = 0.4; + } + // Natural click intervals + if (metrics.clickIntervals.length > 2) { + const variance = calculateVariance(metrics.clickIntervals); + if (variance > 0.01) clickScore += 0.2; + if (clickScore > 1) clickScore = 1; + } + } + + // Weighted sum + const w = config?.weights || { dwell_ratio: 0.30, scroll_score: 0.25, pointer_variance: 0.20, click_rate: 0.25 }; + currentScore = + dwellRatio * (w.dwell_ratio || 0) + + scrollScore * (w.scroll_score || 0) + + pointerScore * (w.pointer_variance || 0) + + clickScore * (w.click_rate || 0); + + if (currentScore > 1) currentScore = 1; + if (currentScore < 0) currentScore = 0; + + return currentScore; + } + + // Calculate variance of an array + function calculateVariance(values) { + if (values.length < 2) return 0; + const mean = values.reduce((a, b) => a + b, 0) / values.length; + return values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / (values.length - 1); + } + + // Send tick to backend + async function sendTick() { + if (!config?.tick?.endpoint) return; + + const now = Date.now(); + const totalTime = (now - metrics.startTime) / 1000; + + const payload = { + session_id: sessionId, + score: Number(currentScore.toFixed(3)), + dwell_ratio: Number((metrics.visibleTime / totalTime).toFixed(3)), + scroll_depth: Number((metrics.maxScrollPercent * 100).toFixed(1)), + clicks: metrics.clickCount, + mouse_moves: metrics.mouseMoves, + key_strokes: metrics.keyStrokes, + touch_events: metrics.touchEvents, + mouse_velocities: metrics.mouseVelocities.slice(-20), // Last 20 values + scroll_velocities: metrics.scrollVelocities.slice(-20), + click_intervals: metrics.clickIntervals.slice(-10), + ts: now + }; + + try { + const response = await fetch(config.tick.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + const data = await response.json(); + // Notify callbacks + scoreCallbacks.forEach(cb => cb(data.score, data.action, data)); + } + } catch (err) { + console.warn('PCA: Tick transmission failed:', err); + } + } + + // WebAuthn step-up + async function triggerWebAuthn() { + if (!config?.step_up?.webauthn?.enabled || !window.PublicKeyCredential) { + return false; + } + + try { + // Get challenge from server + const challengeUrl = `${config.step_up.webauthn.challenge_endpoint}?session_id=${sessionId}`; + const challengeResp = await fetch(challengeUrl); + const challengeData = await challengeResp.json(); + + // Convert base64url challenge to ArrayBuffer + const challenge = base64UrlToArrayBuffer(challengeData.publicKey.challenge); + + const publicKeyRequestOptions = { + challenge: challenge, + timeout: challengeData.publicKey.timeout, + userVerification: challengeData.publicKey.userVerification, + allowCredentials: challengeData.publicKey.allowCredentials || [] + }; + + // Request credential + const credential = await navigator.credentials.get({ publicKey: publicKeyRequestOptions }); + + // Send to server for verification + const verifyResp = await fetch('/pca/v1/webauthn-verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + credential: credentialToJSON(credential) + }) + }); + + const result = await verifyResp.json(); + return result.verified === true; + } catch (e) { + console.log('PCA: WebAuthn step-up failed:', e); + return false; + } + } + + // Proof-of-Work step-up + async function triggerPoW() { + if (!config?.step_up?.pow?.enabled) { + return false; + } + + try { + // Get challenge from server + const challengeResp = await fetch(`/pca/v1/pow-challenge?session_id=${sessionId}`); + const challengeData = await challengeResp.json(); + + const { challenge_id, challenge, difficulty, max_time_ms } = challengeData; + const prefix = '0'.repeat(difficulty); + const startTime = Date.now(); + let nonce = 0; + + // Solve PoW puzzle + while (true) { + const input = challenge + nonce; + const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + if (hashHex.startsWith(prefix)) { + // Found solution - verify with server + const verifyResp = await fetch('/pca/v1/pow-verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + challenge_id: challenge_id, + challenge: challenge, + nonce: nonce + }) + }); + + const result = await verifyResp.json(); + return result.verified === true; + } + + nonce++; + + // Check timeout + if (Date.now() - startTime > max_time_ms) { + console.warn('PCA: PoW step-up timed out'); + return false; + } + + // Yield to prevent UI freeze (every 1000 iterations) + if (nonce % 1000 === 0) { + await new Promise(r => setTimeout(r, 0)); + } + } + } catch (e) { + console.error('PCA: PoW step-up error:', e); + return false; + } + } + + // Trigger step-up based on configured primary method + async function triggerStepUp() { + const methods = config?.step_up; + let success = false; + + if (methods?.primary === 'webauthn' && methods?.webauthn?.enabled && window.PublicKeyCredential) { + success = await triggerWebAuthn(); + } + + if (!success && methods?.pow?.enabled) { + success = await triggerPoW(); + } + + return success; + } + + // Helper: Convert base64url to ArrayBuffer + function base64UrlToArrayBuffer(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - base64.length % 4) % 4); + const binary = atob(base64 + padding); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + + // Helper: Convert credential to JSON-serializable object + function credentialToJSON(credential) { + return { + id: credential.id, + type: credential.type, + rawId: arrayBufferToBase64Url(credential.rawId), + response: { + authenticatorData: arrayBufferToBase64Url(credential.response.authenticatorData), + clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON), + signature: arrayBufferToBase64Url(credential.response.signature) + } + }; + } + + // Helper: Convert ArrayBuffer to base64url + function arrayBufferToBase64Url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + // Initialize SDK + function init(userConfig) { + if (isInitialized) return; + + config = userConfig; + sessionId = generateSessionId(); + isInitialized = true; + + // Visibility change listener + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + metrics.visibleTime += (Date.now() - metrics.lastVisibleTS) / 1000; + } else { + metrics.lastVisibleTS = Date.now(); + } + }); + + // Scroll listener + window.addEventListener('scroll', () => { + const doc = document.documentElement; + const scrollTop = window.pageYOffset || doc.scrollTop; + const viewportHeight = window.innerHeight; + const totalHeight = doc.scrollHeight; + const scrollPercent = totalHeight > 0 ? (scrollTop + viewportHeight) / totalHeight : 0; + + if (scrollPercent > metrics.maxScrollPercent) { + metrics.maxScrollPercent = scrollPercent; + } + + // Track scroll velocity + const now = Date.now(); + if (metrics.lastScrollTime > 0) { + const dt = (now - metrics.lastScrollTime) / 1000; + if (dt > 0) { + const velocity = Math.abs(scrollTop - metrics.lastScrollPos) / dt; + metrics.scrollVelocities.push(velocity); + if (metrics.scrollVelocities.length > 50) metrics.scrollVelocities.shift(); + } + } + metrics.lastScrollPos = scrollTop; + metrics.lastScrollTime = now; + }); + + // Mouse movement listener + document.addEventListener('mousemove', (e) => { + metrics.mouseMoves++; + + // Track mouse velocity + const now = Date.now(); + if (metrics.lastMousePos && metrics.lastMouseTime > 0) { + const dt = (now - metrics.lastMouseTime) / 1000; + if (dt > 0) { + const dx = e.clientX - metrics.lastMousePos.x; + const dy = e.clientY - metrics.lastMousePos.y; + const velocity = Math.sqrt(dx * dx + dy * dy) / dt; + metrics.mouseVelocities.push(velocity); + if (metrics.mouseVelocities.length > 50) metrics.mouseVelocities.shift(); + } + } + metrics.lastMousePos = { x: e.clientX, y: e.clientY }; + metrics.lastMouseTime = now; + }); + + // Click listener + document.addEventListener('click', () => { + const now = Date.now(); + if (metrics.lastClickTime > 0) { + const interval = (now - metrics.lastClickTime) / 1000; + metrics.clickIntervals.push(interval); + if (metrics.clickIntervals.length > 20) metrics.clickIntervals.shift(); + } + metrics.lastClickTime = now; + metrics.clickCount++; + }); + + // Keystroke listener (count only, no content) + document.addEventListener('keydown', () => { + metrics.keyStrokes++; + }); + + // Touch listener (mobile) + document.addEventListener('touchstart', () => { + metrics.touchEvents++; + }); + + // Start tick timer + if (config?.tick?.interval_ms) { + tickTimer = setInterval(() => { + evaluateScore(); + sendTick(); + }, config.tick.interval_ms); + } + } + + // Public API + return { + init, + + getScore: () => currentScore, + + getSessionId: () => sessionId, + + triggerStepUp, + + triggerWebAuthn, + + triggerPoW, + + onScoreUpdate: function(callback) { + scoreCallbacks.push(callback); + // Initial score + evaluateScore(); + callback(currentScore, currentScore >= (config?.thresholds?.score_pass || 0.7) ? 'allow' : 'challenge', null); + }, + + // Manual evaluation + evaluate: () => { + return { + score: evaluateScore(), + session_id: sessionId, + metrics: { + dwell_ratio: metrics.visibleTime / ((Date.now() - metrics.startTime) / 1000), + scroll_depth: metrics.maxScrollPercent, + clicks: metrics.clickCount, + mouse_moves: metrics.mouseMoves + } + }; + }, + + // Force send tick + tick: sendTick, + + // Cleanup + destroy: () => { + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = null; + } + isInitialized = false; + scoreCallbacks = []; + } + }; +})(); + +// Auto-initialize if config is available +if (typeof window !== 'undefined') { + window.PCA = PCA; + + // Try to load config from ai-access.json + fetch('/ai-access.json') + .then(res => res.ok ? res.json() : null) + .catch(() => null) + .then(cfg => { + if (cfg) { + PCA.init(cfg); + } + }); +} + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = PCA; +} diff --git a/scripts/wait-for-core.sh b/scripts/wait-for-core.sh new file mode 100644 index 0000000..a8e361d --- /dev/null +++ b/scripts/wait-for-core.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# ========================================================= +# wait-for-core.sh — Wait for Core infrastructure +# ========================================================= +# Used by lehrer/compliance docker-compose as init container + +MAX_RETRIES=${MAX_RETRIES:-60} +RETRY_INTERVAL=${RETRY_INTERVAL:-5} +HEALTH_URL=${HEALTH_URL:-http://bp-core-health:8099/health} + +echo "Waiting for Core infrastructure at $HEALTH_URL ..." + +for i in $(seq 1 $MAX_RETRIES); do + if curl -sf --max-time 3 "$HEALTH_URL" > /dev/null 2>&1; then + echo "Core infrastructure is ready! (attempt $i)" + exit 0 + fi + echo " Attempt $i/$MAX_RETRIES — Core not ready yet, retrying in ${RETRY_INTERVAL}s..." + sleep $RETRY_INTERVAL +done + +echo "ERROR: Core infrastructure did not become ready after $((MAX_RETRIES * RETRY_INTERVAL))s" +exit 1