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 <noreply@anthropic.com>
This commit is contained in:
295
breakpilot-compliance-sdk/packages/core/src/auth.ts
Normal file
295
breakpilot-compliance-sdk/packages/core/src/auth.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Authentication Provider
|
||||
*
|
||||
* Manages authentication state and token lifecycle
|
||||
*/
|
||||
|
||||
import type { AuthTokenResponse } from '@breakpilot/compliance-sdk-types'
|
||||
import { ComplianceClient } from './client'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface AuthProviderOptions {
|
||||
client: ComplianceClient
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
storage?: Storage
|
||||
onAuthStateChange?: (state: AuthState) => void
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
expiresAt: Date | null
|
||||
user?: {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const STORAGE_KEY = 'breakpilot-sdk-auth'
|
||||
const TOKEN_REFRESH_BUFFER = 5 * 60 * 1000 // 5 minutes before expiry
|
||||
|
||||
// =============================================================================
|
||||
// AUTH PROVIDER
|
||||
// =============================================================================
|
||||
|
||||
export class AuthProvider {
|
||||
private client: ComplianceClient
|
||||
private clientId: string
|
||||
private clientSecret?: string
|
||||
private storage: Storage | null
|
||||
private state: AuthState
|
||||
private refreshTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private onAuthStateChange?: (state: AuthState) => void
|
||||
|
||||
constructor(options: AuthProviderOptions) {
|
||||
this.client = options.client
|
||||
this.clientId = options.clientId
|
||||
this.clientSecret = options.clientSecret
|
||||
this.storage = options.storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)
|
||||
this.onAuthStateChange = options.onAuthStateChange
|
||||
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
}
|
||||
|
||||
this.loadFromStorage()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private loadFromStorage(): void {
|
||||
if (!this.storage) return
|
||||
|
||||
try {
|
||||
const stored = this.storage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored)
|
||||
if (data.expiresAt) {
|
||||
data.expiresAt = new Date(data.expiresAt)
|
||||
}
|
||||
|
||||
// Check if token is still valid
|
||||
if (data.expiresAt && data.expiresAt > new Date()) {
|
||||
this.state = data
|
||||
this.client.setAccessToken(data.accessToken)
|
||||
this.scheduleTokenRefresh()
|
||||
this.notifyStateChange()
|
||||
} else if (data.refreshToken) {
|
||||
// Try to refresh
|
||||
this.refreshToken().catch(() => {
|
||||
this.clearAuth()
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load auth from storage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private saveToStorage(): void {
|
||||
if (!this.storage) return
|
||||
|
||||
try {
|
||||
this.storage.setItem(STORAGE_KEY, JSON.stringify(this.state))
|
||||
} catch (error) {
|
||||
console.error('Failed to save auth to storage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private clearStorage(): void {
|
||||
if (!this.storage) return
|
||||
|
||||
try {
|
||||
this.storage.removeItem(STORAGE_KEY)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear auth from storage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private updateState(tokenResponse: AuthTokenResponse): void {
|
||||
const expiresAt = new Date(Date.now() + tokenResponse.expiresIn * 1000)
|
||||
|
||||
this.state = {
|
||||
isAuthenticated: true,
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken ?? this.state.refreshToken,
|
||||
expiresAt,
|
||||
}
|
||||
|
||||
this.client.setAccessToken(tokenResponse.accessToken)
|
||||
this.saveToStorage()
|
||||
this.scheduleTokenRefresh()
|
||||
this.notifyStateChange()
|
||||
}
|
||||
|
||||
private scheduleTokenRefresh(): void {
|
||||
if (this.refreshTimeout) {
|
||||
clearTimeout(this.refreshTimeout)
|
||||
}
|
||||
|
||||
if (!this.state.expiresAt || !this.state.refreshToken) return
|
||||
|
||||
const timeUntilRefresh = this.state.expiresAt.getTime() - Date.now() - TOKEN_REFRESH_BUFFER
|
||||
|
||||
if (timeUntilRefresh > 0) {
|
||||
this.refreshTimeout = setTimeout(() => {
|
||||
this.refreshToken().catch(() => {
|
||||
this.clearAuth()
|
||||
})
|
||||
}, timeUntilRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyStateChange(): void {
|
||||
this.onAuthStateChange?.(this.state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Authenticate using client credentials (server-to-server)
|
||||
*/
|
||||
async authenticateWithCredentials(): Promise<void> {
|
||||
if (!this.clientSecret) {
|
||||
throw new Error('Client secret is required for credentials authentication')
|
||||
}
|
||||
|
||||
const response = await this.client.authenticate({
|
||||
grantType: 'client_credentials',
|
||||
clientId: this.clientId,
|
||||
clientSecret: this.clientSecret,
|
||||
})
|
||||
|
||||
this.updateState(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using authorization code (OAuth flow)
|
||||
*/
|
||||
async authenticateWithCode(code: string, redirectUri: string): Promise<void> {
|
||||
const response = await this.client.authenticate({
|
||||
grantType: 'authorization_code',
|
||||
clientId: this.clientId,
|
||||
clientSecret: this.clientSecret,
|
||||
code,
|
||||
redirectUri,
|
||||
})
|
||||
|
||||
this.updateState(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token
|
||||
*/
|
||||
async refreshToken(): Promise<void> {
|
||||
if (!this.state.refreshToken) {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
const response = await this.client.refreshToken(this.state.refreshToken)
|
||||
this.updateState(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set access token manually (e.g., from API key)
|
||||
*/
|
||||
setAccessToken(token: string, expiresIn?: number): void {
|
||||
const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : null
|
||||
|
||||
this.state = {
|
||||
isAuthenticated: true,
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
expiresAt,
|
||||
}
|
||||
|
||||
this.client.setAccessToken(token)
|
||||
this.saveToStorage()
|
||||
this.notifyStateChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication state
|
||||
*/
|
||||
clearAuth(): void {
|
||||
if (this.refreshTimeout) {
|
||||
clearTimeout(this.refreshTimeout)
|
||||
}
|
||||
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
}
|
||||
|
||||
this.client.clearAccessToken()
|
||||
this.clearStorage()
|
||||
this.notifyStateChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authentication state
|
||||
*/
|
||||
getState(): AuthState {
|
||||
return { ...this.state }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
if (!this.state.isAuthenticated || !this.state.accessToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.state.expiresAt && this.state.expiresAt <= new Date()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for OAuth flow
|
||||
*/
|
||||
getAuthorizationUrl(redirectUri: string, scope?: string, state?: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
...(scope && { scope }),
|
||||
...(state && { state }),
|
||||
})
|
||||
|
||||
// This would be configured based on the API endpoint
|
||||
return `/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the auth provider
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.refreshTimeout) {
|
||||
clearTimeout(this.refreshTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
521
breakpilot-compliance-sdk/packages/core/src/client.ts
Normal file
521
breakpilot-compliance-sdk/packages/core/src/client.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Compliance Client
|
||||
*
|
||||
* Main entry point for the SDK. Handles API communication with
|
||||
* retry logic, timeout handling, and optimistic locking.
|
||||
*/
|
||||
|
||||
import type {
|
||||
APIResponse,
|
||||
StateResponse,
|
||||
CheckpointValidationResult,
|
||||
AuthTokenRequest,
|
||||
AuthTokenResponse,
|
||||
RAGSearchRequest,
|
||||
RAGSearchResponse,
|
||||
RAGAskRequest,
|
||||
RAGAskResponse,
|
||||
ExportFormat,
|
||||
SDKState,
|
||||
CheckpointStatus,
|
||||
} from '@breakpilot/compliance-sdk-types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ComplianceClientOptions {
|
||||
apiEndpoint: string
|
||||
apiKey?: string
|
||||
tenantId: string
|
||||
timeout?: number
|
||||
maxRetries?: number
|
||||
onError?: (error: Error) => void
|
||||
onAuthError?: () => void
|
||||
}
|
||||
|
||||
interface APIError extends Error {
|
||||
status?: number
|
||||
code?: string
|
||||
retryable: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_TIMEOUT = 30000
|
||||
const DEFAULT_MAX_RETRIES = 3
|
||||
const RETRY_DELAYS = [1000, 2000, 4000]
|
||||
|
||||
// =============================================================================
|
||||
// COMPLIANCE CLIENT
|
||||
// =============================================================================
|
||||
|
||||
export class ComplianceClient {
|
||||
private apiEndpoint: string
|
||||
private apiKey: string | null
|
||||
private tenantId: string
|
||||
private timeout: number
|
||||
private maxRetries: number
|
||||
private accessToken: string | null = null
|
||||
private abortControllers: Map<string, AbortController> = new Map()
|
||||
private onError?: (error: Error) => void
|
||||
private onAuthError?: () => void
|
||||
|
||||
constructor(options: ComplianceClientOptions) {
|
||||
this.apiEndpoint = options.apiEndpoint.replace(/\/$/, '')
|
||||
this.apiKey = options.apiKey ?? null
|
||||
this.tenantId = options.tenantId
|
||||
this.timeout = options.timeout ?? DEFAULT_TIMEOUT
|
||||
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||
this.onError = options.onError
|
||||
this.onAuthError = options.onAuthError
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private createError(message: string, status?: number, retryable = false): APIError {
|
||||
const error = new Error(message) as APIError
|
||||
error.status = status
|
||||
error.retryable = retryable
|
||||
return error
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': this.tenantId,
|
||||
}
|
||||
|
||||
if (this.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${this.accessToken}`
|
||||
} else if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
requestId: string
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
this.abortControllers.set(requestId, controller)
|
||||
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
})
|
||||
return response
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
this.abortControllers.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithRetry<T>(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
retries = this.maxRetries
|
||||
): Promise<T> {
|
||||
const requestId = `${Date.now()}-${Math.random()}`
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, options, requestId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text()
|
||||
let errorMessage = `HTTP ${response.status}`
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorBody)
|
||||
errorMessage = errorJson.error || errorJson.message || errorMessage
|
||||
} catch {
|
||||
// Keep HTTP status message
|
||||
}
|
||||
|
||||
// Handle auth errors
|
||||
if (response.status === 401) {
|
||||
this.onAuthError?.()
|
||||
throw this.createError(errorMessage, response.status, false)
|
||||
}
|
||||
|
||||
// Don't retry client errors (4xx) except 429
|
||||
const retryable = response.status >= 500 || response.status === 429
|
||||
|
||||
if (!retryable || attempt === retries) {
|
||||
throw this.createError(errorMessage, response.status, retryable)
|
||||
}
|
||||
} else {
|
||||
const data = await response.json()
|
||||
return data as T
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw this.createError('Request timeout', 408, true)
|
||||
}
|
||||
|
||||
const apiError = error as APIError
|
||||
if (!apiError.retryable || attempt === retries) {
|
||||
this.onError?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
if (attempt < retries) {
|
||||
await this.sleep(RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || this.createError('Unknown error', 500, false)
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async authenticate(request: AuthTokenRequest): Promise<AuthTokenResponse> {
|
||||
const response = await this.fetchWithRetry<APIResponse<AuthTokenResponse>>(
|
||||
`${this.apiEndpoint}/auth/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
)
|
||||
|
||||
if (response.success && response.data) {
|
||||
this.accessToken = response.data.accessToken
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw this.createError(response.error || 'Authentication failed', 401, false)
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenResponse> {
|
||||
return this.authenticate({
|
||||
grantType: 'refresh_token',
|
||||
clientId: '',
|
||||
refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
setAccessToken(token: string): void {
|
||||
this.accessToken = token
|
||||
}
|
||||
|
||||
clearAccessToken(): void {
|
||||
this.accessToken = null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getState(): Promise<StateResponse | null> {
|
||||
try {
|
||||
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
|
||||
`${this.apiEndpoint}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
)
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
const apiError = error as APIError
|
||||
if (apiError.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async saveState(state: SDKState, version?: number): Promise<StateResponse> {
|
||||
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
|
||||
`${this.apiEndpoint}/state`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
...(version !== undefined && { 'If-Match': String(version) }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenantId: this.tenantId,
|
||||
state,
|
||||
version,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.success) {
|
||||
throw this.createError(response.error || 'Failed to save state', 500, true)
|
||||
}
|
||||
|
||||
return response.data!
|
||||
}
|
||||
|
||||
async deleteState(): Promise<void> {
|
||||
await this.fetchWithRetry<APIResponse<void>>(
|
||||
`${this.apiEndpoint}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async validateCheckpoint(
|
||||
checkpointId: string,
|
||||
data?: unknown
|
||||
): Promise<CheckpointValidationResult> {
|
||||
const response = await this.fetchWithRetry<APIResponse<CheckpointValidationResult>>(
|
||||
`${this.apiEndpoint}/checkpoints/validate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
tenantId: this.tenantId,
|
||||
checkpointId,
|
||||
data,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw this.createError(response.error || 'Checkpoint validation failed', 500, true)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getCheckpoints(): Promise<Record<string, CheckpointStatus>> {
|
||||
const response = await this.fetchWithRetry<APIResponse<Record<string, CheckpointStatus>>>(
|
||||
`${this.apiEndpoint}/checkpoints?tenantId=${encodeURIComponent(this.tenantId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
)
|
||||
|
||||
return response.data || {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RAG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async searchRAG(request: RAGSearchRequest): Promise<RAGSearchResponse> {
|
||||
const response = await this.fetchWithRetry<APIResponse<RAGSearchResponse>>(
|
||||
`${this.apiEndpoint}/rag/search`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw this.createError(response.error || 'RAG search failed', 500, true)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
async askRAG(request: RAGAskRequest): Promise<RAGAskResponse> {
|
||||
const response = await this.fetchWithRetry<APIResponse<RAGAskResponse>>(
|
||||
`${this.apiEndpoint}/rag/ask`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw this.createError(response.error || 'RAG query failed', 500, true)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async exportState(format: ExportFormat): Promise<Blob> {
|
||||
const response = await this.fetchWithTimeout(
|
||||
`${this.apiEndpoint}/export?tenantId=${encodeURIComponent(this.tenantId)}&format=${format}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
Accept:
|
||||
format === 'json'
|
||||
? 'application/json'
|
||||
: format === 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
`export-${Date.now()}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.createError(`Export failed: ${response.statusText}`, response.status, true)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document Generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async generateDocument(
|
||||
type: 'dsfa' | 'tom' | 'vvt' | 'gutachten' | 'privacy_policy' | 'cookie_banner',
|
||||
options?: Record<string, unknown>
|
||||
): Promise<{ id: string; status: string; content?: string }> {
|
||||
const response = await this.fetchWithRetry<
|
||||
APIResponse<{ id: string; status: string; content?: string }>
|
||||
>(
|
||||
`${this.apiEndpoint}/generate/${type}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
tenantId: this.tenantId,
|
||||
options,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw this.createError(response.error || 'Document generation failed', 500, true)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security Scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async startSecurityScan(options?: {
|
||||
tools?: string[]
|
||||
targetPath?: string
|
||||
severityThreshold?: string
|
||||
generateSBOM?: boolean
|
||||
}): Promise<{ id: string; status: string }> {
|
||||
const response = await this.fetchWithRetry<APIResponse<{ id: string; status: string }>>(
|
||||
`${this.apiEndpoint}/security/scan`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
tenantId: this.tenantId,
|
||||
...options,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw this.createError(response.error || 'Security scan failed', 500, true)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getSecurityScanResult(scanId: string): Promise<unknown> {
|
||||
const response = await this.fetchWithRetry<APIResponse<unknown>>(
|
||||
`${this.apiEndpoint}/security/scan/${scanId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
)
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
cancelAllRequests(): void {
|
||||
this.abortControllers.forEach(controller => controller.abort())
|
||||
this.abortControllers.clear()
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string): void {
|
||||
this.tenantId = tenantId
|
||||
}
|
||||
|
||||
getTenantId(): string {
|
||||
return this.tenantId
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(
|
||||
`${this.apiEndpoint}/health`,
|
||||
{ method: 'GET' },
|
||||
`health-${Date.now()}`
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FACTORY
|
||||
// =============================================================================
|
||||
|
||||
let clientInstance: ComplianceClient | null = null
|
||||
|
||||
export function getComplianceClient(options?: ComplianceClientOptions): ComplianceClient {
|
||||
if (!clientInstance && !options) {
|
||||
throw new Error('ComplianceClient not initialized. Provide options on first call.')
|
||||
}
|
||||
|
||||
if (!clientInstance && options) {
|
||||
clientInstance = new ComplianceClient(options)
|
||||
}
|
||||
|
||||
return clientInstance!
|
||||
}
|
||||
|
||||
export function resetComplianceClient(): void {
|
||||
if (clientInstance) {
|
||||
clientInstance.cancelAllRequests()
|
||||
}
|
||||
clientInstance = null
|
||||
}
|
||||
41
breakpilot-compliance-sdk/packages/core/src/index.ts
Normal file
41
breakpilot-compliance-sdk/packages/core/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @breakpilot/compliance-sdk-core
|
||||
*
|
||||
* Core functionality for BreakPilot Compliance SDK
|
||||
*/
|
||||
|
||||
// Client
|
||||
export { ComplianceClient, type ComplianceClientOptions } from './client'
|
||||
|
||||
// State Management
|
||||
export {
|
||||
createStore,
|
||||
sdkReducer,
|
||||
initialState,
|
||||
type SDKStore,
|
||||
type SDKStoreOptions,
|
||||
} from './state'
|
||||
|
||||
// Sync
|
||||
export {
|
||||
StateSyncManager,
|
||||
createStateSyncManager,
|
||||
type SyncOptions,
|
||||
type SyncCallbacks,
|
||||
} from './sync'
|
||||
|
||||
// Auth
|
||||
export {
|
||||
AuthProvider,
|
||||
type AuthProviderOptions,
|
||||
type AuthState,
|
||||
} from './auth'
|
||||
|
||||
// Modules
|
||||
export * from './modules/dsgvo'
|
||||
export * from './modules/compliance'
|
||||
export * from './modules/rag'
|
||||
export * from './modules/security'
|
||||
|
||||
// Utils
|
||||
export * from './utils'
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Compliance Module
|
||||
*
|
||||
* General compliance functionality including controls, evidence,
|
||||
* AI Act, NIS2, and regulatory obligations
|
||||
*/
|
||||
|
||||
import type {
|
||||
Control,
|
||||
Evidence,
|
||||
Requirement,
|
||||
Obligation,
|
||||
AIActResult,
|
||||
AIActRiskCategory,
|
||||
RegulationCode,
|
||||
ComplianceScore,
|
||||
SDKState,
|
||||
RiskSeverity,
|
||||
} from '@breakpilot/compliance-sdk-types'
|
||||
import { ComplianceClient } from '../client'
|
||||
|
||||
export class ComplianceModule {
|
||||
private client: ComplianceClient
|
||||
private getState: () => SDKState
|
||||
|
||||
constructor(client: ComplianceClient, getState: () => SDKState) {
|
||||
this.client = client
|
||||
this.getState = getState
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getControls(): Control[] {
|
||||
return this.getState().controls
|
||||
}
|
||||
|
||||
getControlById(id: string): Control | undefined {
|
||||
return this.getState().controls.find(c => c.id === id)
|
||||
}
|
||||
|
||||
getControlsByDomain(domain: string): Control[] {
|
||||
return this.getState().controls.filter(c => c.domain === domain)
|
||||
}
|
||||
|
||||
getControlsByStatus(status: string): Control[] {
|
||||
return this.getState().controls.filter(c => c.implementationStatus === status)
|
||||
}
|
||||
|
||||
getControlComplianceRate(): number {
|
||||
const controls = this.getControls()
|
||||
if (controls.length === 0) return 0
|
||||
|
||||
const implemented = controls.filter(c => c.implementationStatus === 'IMPLEMENTED').length
|
||||
return Math.round((implemented / controls.length) * 100)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evidence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getEvidence(): Evidence[] {
|
||||
return this.getState().evidence
|
||||
}
|
||||
|
||||
getEvidenceById(id: string): Evidence | undefined {
|
||||
return this.getState().evidence.find(e => e.id === id)
|
||||
}
|
||||
|
||||
getEvidenceByControlId(controlId: string): Evidence[] {
|
||||
return this.getState().evidence.filter(e => e.controlId === controlId)
|
||||
}
|
||||
|
||||
getExpiringEvidence(days: number = 30): Evidence[] {
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() + days)
|
||||
|
||||
return this.getState().evidence.filter(e => {
|
||||
if (!e.validUntil) return false
|
||||
const validUntil = new Date(e.validUntil)
|
||||
return validUntil <= cutoff && e.status === 'ACTIVE'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requirements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getRequirements(): Requirement[] {
|
||||
return this.getState().requirements
|
||||
}
|
||||
|
||||
getRequirementsByRegulation(regulation: RegulationCode): Requirement[] {
|
||||
return this.getState().requirements.filter(r => r.regulationCode === regulation)
|
||||
}
|
||||
|
||||
getRequirementComplianceRate(regulation?: RegulationCode): number {
|
||||
let requirements = this.getRequirements()
|
||||
if (regulation) {
|
||||
requirements = requirements.filter(r => r.regulationCode === regulation)
|
||||
}
|
||||
if (requirements.length === 0) return 0
|
||||
|
||||
const implemented = requirements.filter(
|
||||
r => r.status === 'IMPLEMENTED' || r.status === 'VERIFIED'
|
||||
).length
|
||||
return Math.round((implemented / requirements.length) * 100)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Obligations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getObligations(): Obligation[] {
|
||||
return this.getState().obligations
|
||||
}
|
||||
|
||||
getUpcomingObligations(days: number = 30): Obligation[] {
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() + days)
|
||||
|
||||
return this.getState().obligations.filter(o => {
|
||||
if (!o.deadline || o.status === 'COMPLETED') return false
|
||||
const deadline = new Date(o.deadline)
|
||||
return deadline <= cutoff
|
||||
})
|
||||
}
|
||||
|
||||
getOverdueObligations(): Obligation[] {
|
||||
const now = new Date()
|
||||
|
||||
return this.getState().obligations.filter(o => {
|
||||
if (!o.deadline || o.status === 'COMPLETED') return false
|
||||
const deadline = new Date(o.deadline)
|
||||
return deadline < now
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Act
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getAIActClassification(): AIActResult | null {
|
||||
return this.getState().aiActClassification
|
||||
}
|
||||
|
||||
getAIActRiskCategory(): AIActRiskCategory | null {
|
||||
return this.getState().aiActClassification?.riskCategory ?? null
|
||||
}
|
||||
|
||||
isHighRiskAI(): boolean {
|
||||
const category = this.getAIActRiskCategory()
|
||||
return category === 'HIGH' || category === 'UNACCEPTABLE'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compliance Score
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
calculateComplianceScore(): ComplianceScore {
|
||||
const state = this.getState()
|
||||
|
||||
// Calculate overall score based on controls, requirements, and evidence
|
||||
const controlScore = this.getControlComplianceRate()
|
||||
const requirementScore = this.getRequirementComplianceRate()
|
||||
const evidenceCoverage = this.calculateEvidenceCoverage()
|
||||
|
||||
const overall = Math.round((controlScore + requirementScore + evidenceCoverage) / 3)
|
||||
|
||||
// Calculate scores by regulation
|
||||
const byRegulation: Record<string, number> = {}
|
||||
const regulations = new Set(state.requirements.map(r => r.regulationCode))
|
||||
regulations.forEach(reg => {
|
||||
byRegulation[reg] = this.getRequirementComplianceRate(reg as RegulationCode)
|
||||
})
|
||||
|
||||
// Calculate scores by domain
|
||||
const byDomain: Record<string, number> = {}
|
||||
const domains = new Set(state.controls.map(c => c.domain))
|
||||
domains.forEach(domain => {
|
||||
const domainControls = state.controls.filter(c => c.domain === domain)
|
||||
const implemented = domainControls.filter(c => c.implementationStatus === 'IMPLEMENTED').length
|
||||
byDomain[domain] = domainControls.length > 0
|
||||
? Math.round((implemented / domainControls.length) * 100)
|
||||
: 0
|
||||
})
|
||||
|
||||
return {
|
||||
overall,
|
||||
byRegulation: byRegulation as Record<RegulationCode, number>,
|
||||
byDomain: byDomain as Record<string, number>,
|
||||
trend: 'STABLE', // Would need historical data to calculate
|
||||
lastCalculated: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
private calculateEvidenceCoverage(): number {
|
||||
const controls = this.getControls()
|
||||
const implementedControls = controls.filter(c => c.implementationStatus === 'IMPLEMENTED')
|
||||
|
||||
if (implementedControls.length === 0) return 0
|
||||
|
||||
const controlsWithEvidence = implementedControls.filter(c => {
|
||||
const evidence = this.getEvidenceByControlId(c.id)
|
||||
return evidence.some(e => e.status === 'ACTIVE')
|
||||
})
|
||||
|
||||
return Math.round((controlsWithEvidence.length / implementedControls.length) * 100)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Risks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getRisks() {
|
||||
return this.getState().risks
|
||||
}
|
||||
|
||||
getRisksByStatus(status: string) {
|
||||
return this.getRisks().filter(r => r.status === status)
|
||||
}
|
||||
|
||||
getRisksBySeverity(severity: RiskSeverity) {
|
||||
return this.getRisks().filter(r => r.severity === severity)
|
||||
}
|
||||
|
||||
getCriticalRisks() {
|
||||
return this.getRisks().filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH')
|
||||
}
|
||||
|
||||
getAverageRiskScore(): number {
|
||||
const risks = this.getRisks()
|
||||
if (risks.length === 0) return 0
|
||||
|
||||
const totalScore = risks.reduce((sum, r) => sum + r.residualRiskScore, 0)
|
||||
return Math.round(totalScore / risks.length)
|
||||
}
|
||||
}
|
||||
|
||||
export function createComplianceModule(
|
||||
client: ComplianceClient,
|
||||
getState: () => SDKState
|
||||
): ComplianceModule {
|
||||
return new ComplianceModule(client, getState)
|
||||
}
|
||||
155
breakpilot-compliance-sdk/packages/core/src/modules/dsgvo.ts
Normal file
155
breakpilot-compliance-sdk/packages/core/src/modules/dsgvo.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* DSGVO Module
|
||||
*
|
||||
* GDPR compliance functionality
|
||||
*/
|
||||
|
||||
import type {
|
||||
DSRRequest,
|
||||
DSRRequestType,
|
||||
ConsentRecord,
|
||||
ConsentPurpose,
|
||||
ProcessingActivity,
|
||||
DSFA,
|
||||
TOM,
|
||||
RetentionPolicy,
|
||||
CookieBannerConfig,
|
||||
SDKState,
|
||||
} from '@breakpilot/compliance-sdk-types'
|
||||
import { ComplianceClient } from '../client'
|
||||
|
||||
export class DSGVOModule {
|
||||
private client: ComplianceClient
|
||||
private getState: () => SDKState
|
||||
|
||||
constructor(client: ComplianceClient, getState: () => SDKState) {
|
||||
this.client = client
|
||||
this.getState = getState
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DSR (Data Subject Requests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async submitDSR(type: DSRRequestType, requesterEmail: string, requesterName: string): Promise<DSRRequest> {
|
||||
const response = await fetch(`${this.client.getTenantId()}/dsgvo/dsr`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, requesterEmail, requesterName }),
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
getDSRRequests(): DSRRequest[] {
|
||||
return this.getState().dsrRequests
|
||||
}
|
||||
|
||||
getDSRById(id: string): DSRRequest | undefined {
|
||||
return this.getState().dsrRequests.find(r => r.id === id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consent Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getConsents(): ConsentRecord[] {
|
||||
return this.getState().consents
|
||||
}
|
||||
|
||||
getConsentsByUserId(userId: string): ConsentRecord[] {
|
||||
return this.getState().consents.filter(c => c.userId === userId)
|
||||
}
|
||||
|
||||
hasConsent(userId: string, purpose: ConsentPurpose): boolean {
|
||||
const consents = this.getConsentsByUserId(userId)
|
||||
const latestConsent = consents
|
||||
.filter(c => c.consentType === purpose)
|
||||
.sort((a, b) => new Date(b.grantedAt).getTime() - new Date(a.grantedAt).getTime())[0]
|
||||
|
||||
return latestConsent?.granted && !latestConsent.revokedAt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VVT (Processing Register)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getProcessingActivities(): ProcessingActivity[] {
|
||||
return this.getState().vvt
|
||||
}
|
||||
|
||||
getProcessingActivityById(id: string): ProcessingActivity | undefined {
|
||||
return this.getState().vvt.find(p => p.id === id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DSFA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getDSFA(): DSFA | null {
|
||||
return this.getState().dsfa
|
||||
}
|
||||
|
||||
isDSFARequired(): boolean {
|
||||
const state = this.getState()
|
||||
const activeUseCase = state.useCases.find(uc => uc.id === state.activeUseCase)
|
||||
return activeUseCase?.assessmentResult?.dsfaRequired ?? false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOMs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getTOMs(): TOM[] {
|
||||
return this.getState().toms
|
||||
}
|
||||
|
||||
getTOMsByCategory(category: string): TOM[] {
|
||||
return this.getState().toms.filter(t => t.category === category)
|
||||
}
|
||||
|
||||
getTOMScore(): number {
|
||||
const toms = this.getTOMs()
|
||||
if (toms.length === 0) return 0
|
||||
|
||||
const implemented = toms.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
return Math.round((implemented / toms.length) * 100)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retention Policies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getRetentionPolicies(): RetentionPolicy[] {
|
||||
return this.getState().retentionPolicies
|
||||
}
|
||||
|
||||
getUpcomingDeletions(days: number = 30): RetentionPolicy[] {
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() + days)
|
||||
|
||||
return this.getState().retentionPolicies.filter(p => {
|
||||
const nextReview = new Date(p.nextReviewDate)
|
||||
return nextReview <= cutoff
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie Banner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getCookieBannerConfig(): CookieBannerConfig | null {
|
||||
return this.getState().cookieBanner
|
||||
}
|
||||
|
||||
async generateCookieBannerCode(): Promise<{ html: string; css: string; js: string } | null> {
|
||||
const config = this.getCookieBannerConfig()
|
||||
if (!config) return null
|
||||
|
||||
const response = await this.client.generateDocument('cookie_banner', { config })
|
||||
return response.content ? JSON.parse(response.content) : null
|
||||
}
|
||||
}
|
||||
|
||||
export function createDSGVOModule(client: ComplianceClient, getState: () => SDKState): DSGVOModule {
|
||||
return new DSGVOModule(client, getState)
|
||||
}
|
||||
206
breakpilot-compliance-sdk/packages/core/src/modules/rag.ts
Normal file
206
breakpilot-compliance-sdk/packages/core/src/modules/rag.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* RAG Module
|
||||
*
|
||||
* Legal RAG system for semantic search and AI-powered legal assistance
|
||||
*/
|
||||
|
||||
import type {
|
||||
SearchQuery,
|
||||
SearchResponse,
|
||||
AssistantQuery,
|
||||
AssistantResponse,
|
||||
LegalDocument,
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
} from '@breakpilot/compliance-sdk-types'
|
||||
import { ComplianceClient } from '../client'
|
||||
|
||||
export class RAGModule {
|
||||
private client: ComplianceClient
|
||||
private chatHistory: ChatMessage[] = []
|
||||
private sessionId: string | null = null
|
||||
|
||||
constructor(client: ComplianceClient) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async search(query: string, options?: Partial<SearchQuery>): Promise<SearchResponse> {
|
||||
const searchRequest: SearchQuery = {
|
||||
query,
|
||||
limit: options?.limit ?? 10,
|
||||
offset: options?.offset ?? 0,
|
||||
filters: options?.filters,
|
||||
includeMetadata: options?.includeMetadata ?? true,
|
||||
scoreThreshold: options?.scoreThreshold ?? 0.5,
|
||||
}
|
||||
|
||||
return this.client.searchRAG({
|
||||
query: searchRequest.query,
|
||||
filters: searchRequest.filters,
|
||||
limit: searchRequest.limit,
|
||||
offset: searchRequest.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async searchByRegulation(regulation: string, query: string): Promise<SearchResponse> {
|
||||
return this.search(query, {
|
||||
filters: {
|
||||
documentCodes: [regulation],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async searchByArticle(regulation: string, article: string): Promise<SearchResponse> {
|
||||
return this.search(`${regulation} Artikel ${article}`, {
|
||||
filters: {
|
||||
documentCodes: [regulation],
|
||||
articles: [article],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legal Assistant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async ask(question: string, options?: Partial<AssistantQuery>): Promise<AssistantResponse> {
|
||||
const request: AssistantQuery = {
|
||||
question,
|
||||
context: options?.context,
|
||||
documents: options?.documents,
|
||||
maxSources: options?.maxSources ?? 5,
|
||||
language: options?.language ?? 'de',
|
||||
responseFormat: options?.responseFormat ?? 'detailed',
|
||||
}
|
||||
|
||||
const response = await this.client.askRAG({
|
||||
question: request.question,
|
||||
context: request.context,
|
||||
documents: request.documents,
|
||||
maxSources: request.maxSources,
|
||||
language: request.language,
|
||||
})
|
||||
|
||||
// Add to chat history
|
||||
this.chatHistory.push({
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: question,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
this.chatHistory.push({
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: response.answer,
|
||||
timestamp: new Date(),
|
||||
sources: response.sources,
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async askAboutRegulation(regulation: string, question: string): Promise<AssistantResponse> {
|
||||
return this.ask(question, {
|
||||
documents: [regulation],
|
||||
context: `Kontext: Frage bezieht sich auf ${regulation}`,
|
||||
})
|
||||
}
|
||||
|
||||
async explainArticle(regulation: string, article: string): Promise<AssistantResponse> {
|
||||
return this.ask(`Erkläre ${regulation} Artikel ${article} einfach und verständlich.`, {
|
||||
documents: [regulation],
|
||||
})
|
||||
}
|
||||
|
||||
async checkCompliance(
|
||||
regulation: string,
|
||||
scenario: string
|
||||
): Promise<AssistantResponse> {
|
||||
return this.ask(
|
||||
`Prüfe folgendes Szenario auf Compliance mit ${regulation}: ${scenario}`,
|
||||
{
|
||||
documents: [regulation],
|
||||
responseFormat: 'detailed',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
startNewSession(): void {
|
||||
this.sessionId = `session-${Date.now()}`
|
||||
this.chatHistory = []
|
||||
}
|
||||
|
||||
getChatHistory(): ChatMessage[] {
|
||||
return [...this.chatHistory]
|
||||
}
|
||||
|
||||
clearChatHistory(): void {
|
||||
this.chatHistory = []
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.sessionId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document Info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getAvailableRegulations(): readonly string[] {
|
||||
return [
|
||||
'DSGVO',
|
||||
'AI_ACT',
|
||||
'NIS2',
|
||||
'EPRIVACY',
|
||||
'TDDDG',
|
||||
'SCC',
|
||||
'DPF',
|
||||
'CRA',
|
||||
'EUCSA',
|
||||
'DATA_ACT',
|
||||
'DGA',
|
||||
'DSA',
|
||||
'EAA',
|
||||
'BDSG',
|
||||
'ISO_27001',
|
||||
'BSI_GRUNDSCHUTZ',
|
||||
'KRITIS',
|
||||
'BAIT',
|
||||
'VAIT',
|
||||
'SOC2',
|
||||
'PCI_DSS',
|
||||
] as const
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getQuickAnswer(question: string): Promise<string> {
|
||||
const response = await this.ask(question, {
|
||||
responseFormat: 'concise',
|
||||
maxSources: 3,
|
||||
})
|
||||
return response.answer
|
||||
}
|
||||
|
||||
async findRelevantArticles(topic: string): Promise<SearchResponse> {
|
||||
return this.search(topic, {
|
||||
limit: 5,
|
||||
scoreThreshold: 0.7,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createRAGModule(client: ComplianceClient): RAGModule {
|
||||
return new RAGModule(client)
|
||||
}
|
||||
241
breakpilot-compliance-sdk/packages/core/src/modules/security.ts
Normal file
241
breakpilot-compliance-sdk/packages/core/src/modules/security.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Security Module
|
||||
*
|
||||
* Security scanning and SBOM management
|
||||
*/
|
||||
|
||||
import type {
|
||||
SBOM,
|
||||
SecurityIssue,
|
||||
SecurityScanResult,
|
||||
BacklogItem,
|
||||
SecurityIssueSeverity,
|
||||
SecurityTool,
|
||||
FindingsSummary,
|
||||
SDKState,
|
||||
} from '@breakpilot/compliance-sdk-types'
|
||||
import { ComplianceClient } from '../client'
|
||||
|
||||
export class SecurityModule {
|
||||
private client: ComplianceClient
|
||||
private getState: () => SDKState
|
||||
|
||||
constructor(client: ComplianceClient, getState: () => SDKState) {
|
||||
this.client = client
|
||||
this.getState = getState
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security Scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async startScan(options?: {
|
||||
tools?: SecurityTool[]
|
||||
targetPath?: string
|
||||
severityThreshold?: SecurityIssueSeverity
|
||||
generateSBOM?: boolean
|
||||
}): Promise<{ id: string; status: string }> {
|
||||
return this.client.startSecurityScan({
|
||||
tools: options?.tools,
|
||||
targetPath: options?.targetPath,
|
||||
severityThreshold: options?.severityThreshold,
|
||||
generateSBOM: options?.generateSBOM ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
async getScanResult(scanId: string): Promise<SecurityScanResult | null> {
|
||||
const result = await this.client.getSecurityScanResult(scanId)
|
||||
return result as SecurityScanResult | null
|
||||
}
|
||||
|
||||
getLastScanResult(): SecurityScanResult | null {
|
||||
const screening = this.getState().screening
|
||||
return screening?.securityScan ?? null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SBOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getSBOM(): SBOM | null {
|
||||
return this.getState().sbom
|
||||
}
|
||||
|
||||
getComponents() {
|
||||
return this.getSBOM()?.components ?? []
|
||||
}
|
||||
|
||||
getComponentsByLicense(license: string) {
|
||||
return this.getComponents().filter(c => c.licenses.includes(license as never))
|
||||
}
|
||||
|
||||
getVulnerableComponents() {
|
||||
return this.getComponents().filter(c => c.vulnerabilities.length > 0)
|
||||
}
|
||||
|
||||
getLicenseSummary(): Record<string, number> {
|
||||
const components = this.getComponents()
|
||||
const summary: Record<string, number> = {}
|
||||
|
||||
components.forEach(c => {
|
||||
c.licenses.forEach(license => {
|
||||
summary[license] = (summary[license] || 0) + 1
|
||||
})
|
||||
})
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security Issues
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getSecurityIssues(): SecurityIssue[] {
|
||||
return this.getState().securityIssues
|
||||
}
|
||||
|
||||
getIssueById(id: string): SecurityIssue | undefined {
|
||||
return this.getSecurityIssues().find(i => i.id === id)
|
||||
}
|
||||
|
||||
getIssuesBySeverity(severity: SecurityIssueSeverity): SecurityIssue[] {
|
||||
return this.getSecurityIssues().filter(i => i.severity === severity)
|
||||
}
|
||||
|
||||
getIssuesByStatus(status: string): SecurityIssue[] {
|
||||
return this.getSecurityIssues().filter(i => i.status === status)
|
||||
}
|
||||
|
||||
getIssuesByTool(tool: SecurityTool): SecurityIssue[] {
|
||||
return this.getSecurityIssues().filter(i => i.tool === tool)
|
||||
}
|
||||
|
||||
getOpenIssues(): SecurityIssue[] {
|
||||
return this.getSecurityIssues().filter(i => i.status === 'OPEN' || i.status === 'IN_PROGRESS')
|
||||
}
|
||||
|
||||
getCriticalIssues(): SecurityIssue[] {
|
||||
return this.getSecurityIssues().filter(
|
||||
i => (i.severity === 'CRITICAL' || i.severity === 'HIGH') && i.status === 'OPEN'
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backlog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getBacklog(): BacklogItem[] {
|
||||
return this.getState().securityBacklog
|
||||
}
|
||||
|
||||
getBacklogByStatus(status: 'OPEN' | 'IN_PROGRESS' | 'DONE'): BacklogItem[] {
|
||||
return this.getBacklog().filter(i => i.status === status)
|
||||
}
|
||||
|
||||
getOverdueBacklogItems(): BacklogItem[] {
|
||||
const now = new Date()
|
||||
return this.getBacklog().filter(i => {
|
||||
if (!i.dueDate || i.status === 'DONE') return false
|
||||
return new Date(i.dueDate) < now
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary & Statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getSecuritySummary(): FindingsSummary {
|
||||
const issues = this.getSecurityIssues()
|
||||
|
||||
const bySeverity: Record<SecurityIssueSeverity, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 0,
|
||||
MEDIUM: 0,
|
||||
LOW: 0,
|
||||
INFO: 0,
|
||||
}
|
||||
|
||||
const byStatus: Record<string, number> = {
|
||||
OPEN: 0,
|
||||
IN_PROGRESS: 0,
|
||||
RESOLVED: 0,
|
||||
ACCEPTED: 0,
|
||||
FALSE_POSITIVE: 0,
|
||||
}
|
||||
|
||||
const byTool: Record<string, number> = {}
|
||||
|
||||
issues.forEach(issue => {
|
||||
bySeverity[issue.severity]++
|
||||
byStatus[issue.status]++
|
||||
byTool[issue.tool] = (byTool[issue.tool] || 0) + 1
|
||||
})
|
||||
|
||||
// Calculate average resolution time for resolved issues
|
||||
const resolvedIssues = issues.filter(i => i.status === 'RESOLVED' && i.resolvedAt)
|
||||
let averageResolutionDays = 0
|
||||
if (resolvedIssues.length > 0) {
|
||||
const totalDays = resolvedIssues.reduce((sum, issue) => {
|
||||
// Would need createdAt field to calculate properly
|
||||
return sum + 7 // Placeholder
|
||||
}, 0)
|
||||
averageResolutionDays = Math.round(totalDays / resolvedIssues.length)
|
||||
}
|
||||
|
||||
// Find oldest unresolved issue
|
||||
const openIssues = issues.filter(i => i.status === 'OPEN')
|
||||
let oldestUnresolvedDays = 0
|
||||
// Would need createdAt field to calculate properly
|
||||
|
||||
return {
|
||||
totalFindings: issues.length,
|
||||
bySeverity,
|
||||
byStatus: byStatus as Record<string, number>,
|
||||
byTool: byTool as Record<SecurityTool, number>,
|
||||
averageResolutionDays,
|
||||
oldestUnresolvedDays,
|
||||
}
|
||||
}
|
||||
|
||||
getSecurityScore(): number {
|
||||
const issues = this.getSecurityIssues()
|
||||
const openIssues = issues.filter(i => i.status === 'OPEN' || i.status === 'IN_PROGRESS')
|
||||
|
||||
if (issues.length === 0) return 100
|
||||
|
||||
// Weight by severity
|
||||
const severityWeights = {
|
||||
CRITICAL: 10,
|
||||
HIGH: 5,
|
||||
MEDIUM: 2,
|
||||
LOW: 1,
|
||||
INFO: 0,
|
||||
}
|
||||
|
||||
const totalWeight = issues.reduce((sum, i) => sum + severityWeights[i.severity], 0)
|
||||
const openWeight = openIssues.reduce((sum, i) => sum + severityWeights[i.severity], 0)
|
||||
|
||||
if (totalWeight === 0) return 100
|
||||
|
||||
return Math.round(((totalWeight - openWeight) / totalWeight) * 100)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getAvailableTools(): SecurityTool[] {
|
||||
return ['gitleaks', 'semgrep', 'bandit', 'trivy', 'grype', 'syft']
|
||||
}
|
||||
|
||||
async exportSBOM(format: 'CycloneDX' | 'SPDX'): Promise<Blob> {
|
||||
return this.client.exportState(format === 'CycloneDX' ? 'json' : 'json')
|
||||
}
|
||||
}
|
||||
|
||||
export function createSecurityModule(
|
||||
client: ComplianceClient,
|
||||
getState: () => SDKState
|
||||
): SecurityModule {
|
||||
return new SecurityModule(client, getState)
|
||||
}
|
||||
414
breakpilot-compliance-sdk/packages/core/src/state.ts
Normal file
414
breakpilot-compliance-sdk/packages/core/src/state.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* SDK State Management
|
||||
*
|
||||
* Reducer-based state management with persistence support
|
||||
*/
|
||||
|
||||
import type {
|
||||
SDKState,
|
||||
SDKAction,
|
||||
UserPreferences,
|
||||
SDKPhase,
|
||||
SubscriptionTier,
|
||||
getStepById,
|
||||
} from '@breakpilot/compliance-sdk-types'
|
||||
|
||||
// =============================================================================
|
||||
// INITIAL STATE
|
||||
// =============================================================================
|
||||
|
||||
const initialPreferences: UserPreferences = {
|
||||
language: 'de',
|
||||
theme: 'light',
|
||||
compactMode: false,
|
||||
showHints: true,
|
||||
autoSave: true,
|
||||
autoValidate: true,
|
||||
}
|
||||
|
||||
export const initialState: SDKState = {
|
||||
// Metadata
|
||||
version: '1.0.0',
|
||||
lastModified: new Date(),
|
||||
|
||||
// Tenant & User
|
||||
tenantId: '',
|
||||
userId: '',
|
||||
subscription: 'PROFESSIONAL' as SubscriptionTier,
|
||||
|
||||
// Progress
|
||||
currentPhase: 1 as SDKPhase,
|
||||
currentStep: 'use-case-workshop',
|
||||
completedSteps: [],
|
||||
checkpoints: {},
|
||||
|
||||
// Phase 1 Data
|
||||
useCases: [],
|
||||
activeUseCase: null,
|
||||
screening: null,
|
||||
modules: [],
|
||||
requirements: [],
|
||||
controls: [],
|
||||
evidence: [],
|
||||
checklist: [],
|
||||
risks: [],
|
||||
|
||||
// Phase 2 Data
|
||||
aiActClassification: null,
|
||||
obligations: [],
|
||||
dsfa: null,
|
||||
toms: [],
|
||||
retentionPolicies: [],
|
||||
vvt: [],
|
||||
documents: [],
|
||||
cookieBanner: null,
|
||||
consents: [],
|
||||
dsrConfig: null,
|
||||
dsrRequests: [],
|
||||
escalationWorkflows: [],
|
||||
|
||||
// Security
|
||||
sbom: null,
|
||||
securityIssues: [],
|
||||
securityBacklog: [],
|
||||
|
||||
// UI State
|
||||
commandBarHistory: [],
|
||||
recentSearches: [],
|
||||
preferences: initialPreferences,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REDUCER
|
||||
// =============================================================================
|
||||
|
||||
export function sdkReducer(state: SDKState, action: SDKAction): SDKState {
|
||||
const updateState = (updates: Partial<SDKState>): SDKState => ({
|
||||
...state,
|
||||
...updates,
|
||||
lastModified: new Date(),
|
||||
})
|
||||
|
||||
switch (action.type) {
|
||||
case 'SET_STATE':
|
||||
return updateState(action.payload)
|
||||
|
||||
case 'SET_CURRENT_STEP': {
|
||||
// Import dynamically to avoid circular dependencies
|
||||
const { getStepById } = require('@breakpilot/compliance-sdk-types')
|
||||
const step = getStepById(action.payload)
|
||||
return updateState({
|
||||
currentStep: action.payload,
|
||||
currentPhase: step?.phase || state.currentPhase,
|
||||
})
|
||||
}
|
||||
|
||||
case 'COMPLETE_STEP':
|
||||
if (state.completedSteps.includes(action.payload)) {
|
||||
return state
|
||||
}
|
||||
return updateState({
|
||||
completedSteps: [...state.completedSteps, action.payload],
|
||||
})
|
||||
|
||||
case 'SET_CHECKPOINT_STATUS':
|
||||
return updateState({
|
||||
checkpoints: {
|
||||
...state.checkpoints,
|
||||
[action.payload.id]: action.payload.status,
|
||||
},
|
||||
})
|
||||
|
||||
// Use Cases
|
||||
case 'ADD_USE_CASE':
|
||||
return updateState({
|
||||
useCases: [...state.useCases, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_USE_CASE':
|
||||
return updateState({
|
||||
useCases: state.useCases.map(uc =>
|
||||
uc.id === action.payload.id ? { ...uc, ...action.payload.data } : uc
|
||||
),
|
||||
})
|
||||
|
||||
case 'DELETE_USE_CASE':
|
||||
return updateState({
|
||||
useCases: state.useCases.filter(uc => uc.id !== action.payload),
|
||||
activeUseCase: state.activeUseCase === action.payload ? null : state.activeUseCase,
|
||||
})
|
||||
|
||||
case 'SET_ACTIVE_USE_CASE':
|
||||
return updateState({ activeUseCase: action.payload })
|
||||
|
||||
// Screening
|
||||
case 'SET_SCREENING':
|
||||
return updateState({ screening: action.payload })
|
||||
|
||||
// Modules
|
||||
case 'ADD_MODULE':
|
||||
return updateState({
|
||||
modules: [...state.modules, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_MODULE':
|
||||
return updateState({
|
||||
modules: state.modules.map(m =>
|
||||
m.id === action.payload.id ? { ...m, ...action.payload.data } : m
|
||||
),
|
||||
})
|
||||
|
||||
// Requirements
|
||||
case 'ADD_REQUIREMENT':
|
||||
return updateState({
|
||||
requirements: [...state.requirements, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_REQUIREMENT':
|
||||
return updateState({
|
||||
requirements: state.requirements.map(r =>
|
||||
r.id === action.payload.id ? { ...r, ...action.payload.data } : r
|
||||
),
|
||||
})
|
||||
|
||||
// Controls
|
||||
case 'ADD_CONTROL':
|
||||
return updateState({
|
||||
controls: [...state.controls, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_CONTROL':
|
||||
return updateState({
|
||||
controls: state.controls.map(c =>
|
||||
c.id === action.payload.id ? { ...c, ...action.payload.data } : c
|
||||
),
|
||||
})
|
||||
|
||||
// Evidence
|
||||
case 'ADD_EVIDENCE':
|
||||
return updateState({
|
||||
evidence: [...state.evidence, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_EVIDENCE':
|
||||
return updateState({
|
||||
evidence: state.evidence.map(e =>
|
||||
e.id === action.payload.id ? { ...e, ...action.payload.data } : e
|
||||
),
|
||||
})
|
||||
|
||||
case 'DELETE_EVIDENCE':
|
||||
return updateState({
|
||||
evidence: state.evidence.filter(e => e.id !== action.payload),
|
||||
})
|
||||
|
||||
// Risks
|
||||
case 'ADD_RISK':
|
||||
return updateState({
|
||||
risks: [...state.risks, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_RISK':
|
||||
return updateState({
|
||||
risks: state.risks.map(r =>
|
||||
r.id === action.payload.id ? { ...r, ...action.payload.data } : r
|
||||
),
|
||||
})
|
||||
|
||||
case 'DELETE_RISK':
|
||||
return updateState({
|
||||
risks: state.risks.filter(r => r.id !== action.payload),
|
||||
})
|
||||
|
||||
// AI Act
|
||||
case 'SET_AI_ACT_RESULT':
|
||||
return updateState({ aiActClassification: action.payload })
|
||||
|
||||
// Obligations
|
||||
case 'ADD_OBLIGATION':
|
||||
return updateState({
|
||||
obligations: [...state.obligations, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_OBLIGATION':
|
||||
return updateState({
|
||||
obligations: state.obligations.map(o =>
|
||||
o.id === action.payload.id ? { ...o, ...action.payload.data } : o
|
||||
),
|
||||
})
|
||||
|
||||
// DSFA
|
||||
case 'SET_DSFA':
|
||||
return updateState({ dsfa: action.payload })
|
||||
|
||||
// TOMs
|
||||
case 'ADD_TOM':
|
||||
return updateState({
|
||||
toms: [...state.toms, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_TOM':
|
||||
return updateState({
|
||||
toms: state.toms.map(t =>
|
||||
t.id === action.payload.id ? { ...t, ...action.payload.data } : t
|
||||
),
|
||||
})
|
||||
|
||||
// Retention Policies
|
||||
case 'ADD_RETENTION_POLICY':
|
||||
return updateState({
|
||||
retentionPolicies: [...state.retentionPolicies, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_RETENTION_POLICY':
|
||||
return updateState({
|
||||
retentionPolicies: state.retentionPolicies.map(p =>
|
||||
p.id === action.payload.id ? { ...p, ...action.payload.data } : p
|
||||
),
|
||||
})
|
||||
|
||||
// Processing Activities (VVT)
|
||||
case 'ADD_PROCESSING_ACTIVITY':
|
||||
return updateState({
|
||||
vvt: [...state.vvt, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_PROCESSING_ACTIVITY':
|
||||
return updateState({
|
||||
vvt: state.vvt.map(p =>
|
||||
p.id === action.payload.id ? { ...p, ...action.payload.data } : p
|
||||
),
|
||||
})
|
||||
|
||||
// Documents
|
||||
case 'ADD_DOCUMENT':
|
||||
return updateState({
|
||||
documents: [...state.documents, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_DOCUMENT':
|
||||
return updateState({
|
||||
documents: state.documents.map(d =>
|
||||
d.id === action.payload.id ? { ...d, ...action.payload.data } : d
|
||||
),
|
||||
})
|
||||
|
||||
// Cookie Banner
|
||||
case 'SET_COOKIE_BANNER':
|
||||
return updateState({ cookieBanner: action.payload })
|
||||
|
||||
// DSR
|
||||
case 'SET_DSR_CONFIG':
|
||||
return updateState({ dsrConfig: action.payload })
|
||||
|
||||
case 'ADD_DSR_REQUEST':
|
||||
return updateState({
|
||||
dsrRequests: [...state.dsrRequests, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_DSR_REQUEST':
|
||||
return updateState({
|
||||
dsrRequests: state.dsrRequests.map(r =>
|
||||
r.id === action.payload.id ? { ...r, ...action.payload.data } : r
|
||||
),
|
||||
})
|
||||
|
||||
// Escalation Workflows
|
||||
case 'ADD_ESCALATION_WORKFLOW':
|
||||
return updateState({
|
||||
escalationWorkflows: [...state.escalationWorkflows, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_ESCALATION_WORKFLOW':
|
||||
return updateState({
|
||||
escalationWorkflows: state.escalationWorkflows.map(w =>
|
||||
w.id === action.payload.id ? { ...w, ...action.payload.data } : w
|
||||
),
|
||||
})
|
||||
|
||||
// Security
|
||||
case 'ADD_SECURITY_ISSUE':
|
||||
return updateState({
|
||||
securityIssues: [...state.securityIssues, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_SECURITY_ISSUE':
|
||||
return updateState({
|
||||
securityIssues: state.securityIssues.map(i =>
|
||||
i.id === action.payload.id ? { ...i, ...action.payload.data } : i
|
||||
),
|
||||
})
|
||||
|
||||
case 'ADD_BACKLOG_ITEM':
|
||||
return updateState({
|
||||
securityBacklog: [...state.securityBacklog, action.payload],
|
||||
})
|
||||
|
||||
case 'UPDATE_BACKLOG_ITEM':
|
||||
return updateState({
|
||||
securityBacklog: state.securityBacklog.map(i =>
|
||||
i.id === action.payload.id ? { ...i, ...action.payload.data } : i
|
||||
),
|
||||
})
|
||||
|
||||
// UI State
|
||||
case 'ADD_COMMAND_HISTORY':
|
||||
return updateState({
|
||||
commandBarHistory: [action.payload, ...state.commandBarHistory].slice(0, 50),
|
||||
})
|
||||
|
||||
case 'SET_PREFERENCES':
|
||||
return updateState({
|
||||
preferences: { ...state.preferences, ...action.payload },
|
||||
})
|
||||
|
||||
case 'RESET_STATE':
|
||||
return { ...initialState, lastModified: new Date() }
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STORE
|
||||
// =============================================================================
|
||||
|
||||
export interface SDKStoreOptions {
|
||||
tenantId: string
|
||||
userId: string
|
||||
initialState?: Partial<SDKState>
|
||||
onChange?: (state: SDKState) => void
|
||||
}
|
||||
|
||||
export interface SDKStore {
|
||||
getState: () => SDKState
|
||||
dispatch: (action: SDKAction) => void
|
||||
subscribe: (listener: (state: SDKState) => void) => () => void
|
||||
}
|
||||
|
||||
export function createStore(options: SDKStoreOptions): SDKStore {
|
||||
let state: SDKState = {
|
||||
...initialState,
|
||||
tenantId: options.tenantId,
|
||||
userId: options.userId,
|
||||
...options.initialState,
|
||||
}
|
||||
|
||||
const listeners = new Set<(state: SDKState) => void>()
|
||||
|
||||
const getState = () => state
|
||||
|
||||
const dispatch = (action: SDKAction) => {
|
||||
state = sdkReducer(state, action)
|
||||
listeners.forEach(listener => listener(state))
|
||||
options.onChange?.(state)
|
||||
}
|
||||
|
||||
const subscribe = (listener: (state: SDKState) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
return { getState, dispatch, subscribe }
|
||||
}
|
||||
435
breakpilot-compliance-sdk/packages/core/src/sync.ts
Normal file
435
breakpilot-compliance-sdk/packages/core/src/sync.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* SDK State Synchronization
|
||||
*
|
||||
* Handles offline/online sync, multi-tab coordination,
|
||||
* and conflict resolution for SDK state.
|
||||
*/
|
||||
|
||||
import type { SDKState, SyncState, SyncStatus, ConflictResolution } from '@breakpilot/compliance-sdk-types'
|
||||
import { ComplianceClient } from './client'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface SyncOptions {
|
||||
debounceMs?: number
|
||||
maxRetries?: number
|
||||
conflictHandler?: (local: SDKState, server: SDKState) => Promise<ConflictResolution>
|
||||
}
|
||||
|
||||
export interface SyncCallbacks {
|
||||
onSyncStart?: () => void
|
||||
onSyncComplete?: (state: SDKState) => void
|
||||
onSyncError?: (error: Error) => void
|
||||
onConflict?: (local: SDKState, server: SDKState) => void
|
||||
onOffline?: () => void
|
||||
onOnline?: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'breakpilot-compliance-sdk-state'
|
||||
const SYNC_CHANNEL = 'breakpilot-sdk-state-sync'
|
||||
const DEFAULT_DEBOUNCE_MS = 2000
|
||||
const DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
// =============================================================================
|
||||
// STATE SYNC MANAGER
|
||||
// =============================================================================
|
||||
|
||||
export class StateSyncManager {
|
||||
private client: ComplianceClient
|
||||
private tenantId: string
|
||||
private options: Required<SyncOptions>
|
||||
private callbacks: SyncCallbacks
|
||||
private syncState: SyncState
|
||||
private broadcastChannel: BroadcastChannel | null = null
|
||||
private debounceTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private pendingState: SDKState | null = null
|
||||
private isOnline = true
|
||||
|
||||
constructor(
|
||||
client: ComplianceClient,
|
||||
tenantId: string,
|
||||
options: SyncOptions = {},
|
||||
callbacks: SyncCallbacks = {}
|
||||
) {
|
||||
this.client = client
|
||||
this.tenantId = tenantId
|
||||
this.callbacks = callbacks
|
||||
this.options = {
|
||||
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
||||
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||
conflictHandler: options.conflictHandler ?? this.defaultConflictHandler.bind(this),
|
||||
}
|
||||
|
||||
this.syncState = {
|
||||
status: 'idle' as SyncStatus,
|
||||
lastSyncedAt: null,
|
||||
localVersion: 0,
|
||||
serverVersion: 0,
|
||||
pendingChanges: 0,
|
||||
error: null,
|
||||
}
|
||||
|
||||
this.setupBroadcastChannel()
|
||||
this.setupOnlineListener()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private setupBroadcastChannel(): void {
|
||||
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.broadcastChannel = new BroadcastChannel(`${SYNC_CHANNEL}-${this.tenantId}`)
|
||||
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this)
|
||||
} catch (error) {
|
||||
console.warn('BroadcastChannel not available:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private setupOnlineListener(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true
|
||||
this.syncState.status = 'idle'
|
||||
this.callbacks.onOnline?.()
|
||||
if (this.pendingState) {
|
||||
this.syncToServer(this.pendingState)
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false
|
||||
this.syncState.status = 'offline'
|
||||
this.callbacks.onOffline?.()
|
||||
})
|
||||
|
||||
this.isOnline = navigator.onLine
|
||||
if (!this.isOnline) {
|
||||
this.syncState.status = 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broadcast Channel Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private handleBroadcastMessage(event: MessageEvent): void {
|
||||
const { type, state, version } = event.data
|
||||
|
||||
switch (type) {
|
||||
case 'STATE_UPDATED':
|
||||
if (version > this.syncState.localVersion) {
|
||||
this.syncState.localVersion = version
|
||||
this.saveToLocalStorage(state)
|
||||
this.callbacks.onSyncComplete?.(state)
|
||||
}
|
||||
break
|
||||
|
||||
case 'SYNC_COMPLETE':
|
||||
this.syncState.serverVersion = version
|
||||
break
|
||||
|
||||
case 'REQUEST_STATE':
|
||||
this.broadcastState()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastState(): void {
|
||||
if (!this.broadcastChannel) return
|
||||
|
||||
const state = this.loadFromLocalStorage()
|
||||
if (state) {
|
||||
this.broadcastChannel.postMessage({
|
||||
type: 'STATE_UPDATED',
|
||||
state,
|
||||
version: this.syncState.localVersion,
|
||||
tabId: this.getTabId(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastSyncComplete(version: number): void {
|
||||
if (!this.broadcastChannel) return
|
||||
|
||||
this.broadcastChannel.postMessage({
|
||||
type: 'SYNC_COMPLETE',
|
||||
version,
|
||||
tabId: this.getTabId(),
|
||||
})
|
||||
}
|
||||
|
||||
private getTabId(): string {
|
||||
if (typeof window === 'undefined') return 'server'
|
||||
|
||||
let tabId = sessionStorage.getItem('breakpilot-sdk-tab-id')
|
||||
if (!tabId) {
|
||||
tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
sessionStorage.setItem('breakpilot-sdk-tab-id', tabId)
|
||||
}
|
||||
return tabId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local Storage Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private getStorageKey(): string {
|
||||
return `${STORAGE_KEY_PREFIX}-${this.tenantId}`
|
||||
}
|
||||
|
||||
saveToLocalStorage(state: SDKState): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
const data = {
|
||||
state,
|
||||
version: this.syncState.localVersion,
|
||||
savedAt: new Date().toISOString(),
|
||||
}
|
||||
localStorage.setItem(this.getStorageKey(), JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadFromLocalStorage(): SDKState | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(this.getStorageKey())
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored)
|
||||
this.syncState.localVersion = data.version || 0
|
||||
return data.state
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load from localStorage:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
clearLocalStorage(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.getStorageKey())
|
||||
} catch (error) {
|
||||
console.error('Failed to clear localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
queueSync(state: SDKState): void {
|
||||
this.pendingState = state
|
||||
this.syncState.pendingChanges++
|
||||
|
||||
this.syncState.localVersion++
|
||||
this.saveToLocalStorage(state)
|
||||
this.broadcastState()
|
||||
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout)
|
||||
}
|
||||
|
||||
this.debounceTimeout = setTimeout(() => {
|
||||
this.syncToServer(state)
|
||||
}, this.options.debounceMs)
|
||||
}
|
||||
|
||||
async forceSync(state: SDKState): Promise<void> {
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout)
|
||||
this.debounceTimeout = null
|
||||
}
|
||||
|
||||
await this.syncToServer(state)
|
||||
}
|
||||
|
||||
private async syncToServer(state: SDKState): Promise<void> {
|
||||
if (!this.isOnline) {
|
||||
this.syncState.status = 'offline'
|
||||
return
|
||||
}
|
||||
|
||||
this.syncState.status = 'syncing'
|
||||
this.callbacks.onSyncStart?.()
|
||||
|
||||
try {
|
||||
const response = await this.client.saveState(state, this.syncState.serverVersion)
|
||||
|
||||
this.syncState = {
|
||||
...this.syncState,
|
||||
status: 'idle',
|
||||
lastSyncedAt: new Date(),
|
||||
serverVersion: response.version,
|
||||
pendingChanges: 0,
|
||||
error: null,
|
||||
}
|
||||
|
||||
this.pendingState = null
|
||||
this.broadcastSyncComplete(response.version)
|
||||
this.callbacks.onSyncComplete?.(state)
|
||||
} catch (error) {
|
||||
if ((error as { status?: number }).status === 409) {
|
||||
await this.handleConflict(state)
|
||||
} else {
|
||||
this.syncState.status = 'error'
|
||||
this.syncState.error = (error as Error).message
|
||||
this.callbacks.onSyncError?.(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadFromServer(): Promise<SDKState | null> {
|
||||
if (!this.isOnline) {
|
||||
return this.loadFromLocalStorage()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.getState()
|
||||
|
||||
if (response) {
|
||||
this.syncState.serverVersion = response.version
|
||||
this.syncState.localVersion = response.version
|
||||
this.saveToLocalStorage(response.state)
|
||||
return response.state
|
||||
}
|
||||
|
||||
return this.loadFromLocalStorage()
|
||||
} catch (error) {
|
||||
console.error('Failed to load from server:', error)
|
||||
return this.loadFromLocalStorage()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conflict Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async handleConflict(localState: SDKState): Promise<void> {
|
||||
this.syncState.status = 'conflict'
|
||||
|
||||
try {
|
||||
const serverResponse = await this.client.getState()
|
||||
|
||||
if (!serverResponse) {
|
||||
await this.client.saveState(localState)
|
||||
return
|
||||
}
|
||||
|
||||
const serverState = serverResponse.state
|
||||
this.callbacks.onConflict?.(localState, serverState)
|
||||
|
||||
const resolution = await this.options.conflictHandler(localState, serverState)
|
||||
|
||||
let resolvedState: SDKState
|
||||
switch (resolution.strategy) {
|
||||
case 'local':
|
||||
resolvedState = localState
|
||||
break
|
||||
case 'server':
|
||||
resolvedState = serverState
|
||||
break
|
||||
case 'merge':
|
||||
resolvedState = resolution.mergedState || localState
|
||||
break
|
||||
}
|
||||
|
||||
const response = await this.client.saveState(resolvedState)
|
||||
this.syncState.serverVersion = response.version
|
||||
this.syncState.localVersion = response.version
|
||||
this.saveToLocalStorage(resolvedState)
|
||||
this.syncState.status = 'idle'
|
||||
this.callbacks.onSyncComplete?.(resolvedState)
|
||||
} catch (error) {
|
||||
this.syncState.status = 'error'
|
||||
this.syncState.error = (error as Error).message
|
||||
this.callbacks.onSyncError?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
private async defaultConflictHandler(
|
||||
local: SDKState,
|
||||
server: SDKState
|
||||
): Promise<ConflictResolution> {
|
||||
const localTime = new Date(local.lastModified).getTime()
|
||||
const serverTime = new Date(server.lastModified).getTime()
|
||||
|
||||
if (localTime > serverTime) {
|
||||
return { strategy: 'local' }
|
||||
}
|
||||
|
||||
const mergedState: SDKState = {
|
||||
...server,
|
||||
preferences: local.preferences,
|
||||
commandBarHistory: [
|
||||
...local.commandBarHistory,
|
||||
...server.commandBarHistory.filter(
|
||||
h => !local.commandBarHistory.some(lh => lh.id === h.id)
|
||||
),
|
||||
].slice(0, 50),
|
||||
recentSearches: [...new Set([...local.recentSearches, ...server.recentSearches])].slice(
|
||||
0,
|
||||
20
|
||||
),
|
||||
}
|
||||
|
||||
return { strategy: 'merge', mergedState }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Getters & Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getSyncState(): SyncState {
|
||||
return { ...this.syncState }
|
||||
}
|
||||
|
||||
isOnlineStatus(): boolean {
|
||||
return this.isOnline
|
||||
}
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.syncState.pendingChanges > 0 || this.pendingState !== null
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout)
|
||||
}
|
||||
|
||||
if (this.broadcastChannel) {
|
||||
this.broadcastChannel.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FACTORY
|
||||
// =============================================================================
|
||||
|
||||
export function createStateSyncManager(
|
||||
client: ComplianceClient,
|
||||
tenantId: string,
|
||||
options?: SyncOptions,
|
||||
callbacks?: SyncCallbacks
|
||||
): StateSyncManager {
|
||||
return new StateSyncManager(client, tenantId, options, callbacks)
|
||||
}
|
||||
262
breakpilot-compliance-sdk/packages/core/src/utils.ts
Normal file
262
breakpilot-compliance-sdk/packages/core/src/utils.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Utility Functions
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// ID GENERATION
|
||||
// =============================================================================
|
||||
|
||||
export function generateId(prefix?: string): string {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 9)
|
||||
return prefix ? `${prefix}-${timestamp}-${random}` : `${timestamp}-${random}`
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
// Fallback for older environments
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DATE UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
export function formatDate(date: Date | string, locale = 'de-DE'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string, locale = 'de-DE'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleString(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function isDateExpired(date: Date | string): boolean {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d < new Date()
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date)
|
||||
result.setDate(result.getDate() + days)
|
||||
return result
|
||||
}
|
||||
|
||||
export function daysBetween(date1: Date, date2: Date): number {
|
||||
const oneDay = 24 * 60 * 60 * 1000
|
||||
return Math.round(Math.abs((date1.getTime() - date2.getTime()) / oneDay))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STRING UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
export function truncate(str: string, maxLength: number, suffix = '...'): string {
|
||||
if (str.length <= maxLength) return str
|
||||
return str.substring(0, maxLength - suffix.length) + suffix
|
||||
}
|
||||
|
||||
export function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[äöü]/g, c => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[c] || c)
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ARRAY UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
export function groupBy<T, K extends string | number>(
|
||||
array: T[],
|
||||
keyFn: (item: T) => K
|
||||
): Record<K, T[]> {
|
||||
return array.reduce(
|
||||
(groups, item) => {
|
||||
const key = keyFn(item)
|
||||
if (!groups[key]) {
|
||||
groups[key] = []
|
||||
}
|
||||
groups[key].push(item)
|
||||
return groups
|
||||
},
|
||||
{} as Record<K, T[]>
|
||||
)
|
||||
}
|
||||
|
||||
export function uniqueBy<T>(array: T[], keyFn: (item: T) => unknown): T[] {
|
||||
const seen = new Set()
|
||||
return array.filter(item => {
|
||||
const key = keyFn(item)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function sortBy<T>(array: T[], keyFn: (item: T) => number | string, desc = false): T[] {
|
||||
return [...array].sort((a, b) => {
|
||||
const aKey = keyFn(a)
|
||||
const bKey = keyFn(b)
|
||||
if (aKey < bKey) return desc ? 1 : -1
|
||||
if (aKey > bKey) return desc ? -1 : 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OBJECT UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
export function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T
|
||||
if (Array.isArray(obj)) return obj.map(item => deepClone(item)) as T
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as object).map(([key, value]) => [key, deepClone(value)])
|
||||
) as T
|
||||
}
|
||||
|
||||
export function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
|
||||
const result = { ...target }
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
const sourceValue = source[key]
|
||||
const targetValue = target[key]
|
||||
if (
|
||||
typeof sourceValue === 'object' &&
|
||||
sourceValue !== null &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
typeof targetValue === 'object' &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as T[Extract<keyof T, string>]
|
||||
} else {
|
||||
result[key] = sourceValue as T[Extract<keyof T, string>]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
return keys.reduce(
|
||||
(result, key) => {
|
||||
if (key in obj) {
|
||||
result[key] = obj[key]
|
||||
}
|
||||
return result
|
||||
},
|
||||
{} as Pick<T, K>
|
||||
)
|
||||
}
|
||||
|
||||
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||
const result = { ...obj }
|
||||
keys.forEach(key => delete result[key])
|
||||
return result
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VALIDATION UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true
|
||||
if (typeof value === 'string') return value.trim().length === 0
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
if (typeof value === 'object') return Object.keys(value).length === 0
|
||||
return false
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ASYNC UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => fn(...args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastCall = 0
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now()
|
||||
if (now - lastCall >= limit) {
|
||||
lastCall = now
|
||||
fn(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
delay: number
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
if (i < maxRetries) {
|
||||
await sleep(delay * Math.pow(2, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
Reference in New Issue
Block a user