diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83fa358..0bf8894 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,15 @@ jobs: - name: Build run: npm run build + - name: Check bundle budgets + run: node scripts/bundle-budget.mjs + + - name: Validate disaster recovery + run: npm run recovery:validate + + - name: Check operational health + run: npm run health:check + - name: Install Playwright browser run: npx playwright install --with-deps chromium diff --git a/README.md b/README.md index 315ba24..6aa5eb0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ or submits a transaction. The app is a small stable **core** plus any number of independent **feature slices**. Each tool is one directory under `features/` that owns its logic, validation, state, UI, tests, fixtures, request mocks and documentation. The -tool registry is *generated* from those directories, so adding a tool requires +tool registry is _generated_ from those directories, so adding a tool requires creating one new directory and editing nothing else — which is what lets many contributors work in parallel without ever conflicting. @@ -22,15 +22,15 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) and ## Tools -| Tool | What it does | -| --- | --- | -| Address Validator | Validates Stellar addresses and explains exactly why one is rejected | -| Balance Viewer | Every balance an account holds, including pool shares | -| Trustline Checker | Whether an account trusts a specific asset from a specific issuer | -| Payment QR Generator | Builds a SEP-0007 request and renders it as a QR code | -| Transaction Lookup | Ledger, fee, memo, result and operations for a transaction hash | -| Freighter Connect | Detects the wallet and warns about a network mismatch | -| Testnet Faucet | Funds a testnet account through Friendbot | +| Tool | What it does | +| -------------------- | -------------------------------------------------------------------- | +| Address Validator | Validates Stellar addresses and explains exactly why one is rejected | +| Balance Viewer | Every balance an account holds, including pool shares | +| Trustline Checker | Whether an account trusts a specific asset from a specific issuer | +| Payment QR Generator | Builds a SEP-0007 request and renders it as a QR code | +| Transaction Lookup | Ledger, fee, memo, result and operations for a transaction hash | +| Freighter Connect | Detects the wallet and warns about a network mismatch | +| Testnet Faucet | Funds a testnet account through Friendbot | ## Tech stack @@ -72,6 +72,9 @@ npm run test:coverage # unit tests with a coverage summary npm run lint npm run build npm run check # everything CI runs +npm run bundle:check # check per-feature bundle size budgets +npm run recovery:validate # validate data integrity after restore +npm run health:check # operational health dashboard ``` ## Contributing @@ -92,6 +95,15 @@ Maintainers publish contributor work in independent batches of five using the `coverage/coverage-summary.json`. Coverage is informational and does not enforce a global percentage threshold. +## Operations and Maintenance + +See documentation for operational workflows: + +- [RBAC.md](./docs/RBAC.md) - Role-based access control system +- [DISASTER_RECOVERY.md](./docs/DISASTER_RECOVERY.md) - Data validation after restore +- [OPERATIONAL_HEALTH.md](./docs/OPERATIONAL_HEALTH.md) - Health monitoring dashboard +- [BUNDLE_BUDGET.md](./docs/BUNDLE_BUDGET.md) - Per-feature bundle size enforcement + ## Security See [SECURITY.md](./SECURITY.md). Utix is read-only by design. diff --git a/__tests__/bundle-budget.test.ts b/__tests__/bundle-budget.test.ts new file mode 100644 index 0000000..3e0695d --- /dev/null +++ b/__tests__/bundle-budget.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +describe('Bundle budget configuration', () => { + it('has bundle baseline file', () => { + const baselinePath = join(process.cwd(), 'bundle-baseline.json'); + expect(existsSync(baselinePath)).toBe(true); + }); + + it('has bundle budget script', () => { + const scriptPath = join(process.cwd(), 'scripts/bundle-budget.mjs'); + expect(existsSync(scriptPath)).toBe(true); + }); + + it('baseline contains valid structure', () => { + const baselinePath = join(process.cwd(), 'bundle-baseline.json'); + + if (existsSync(baselinePath)) { + const baseline = require(baselinePath); + + for (const [feature, data] of Object.entries(baseline)) { + expect(typeof feature).toBe('string'); + expect(typeof data.totalSize).toBe('number'); + expect(typeof data.sizeKB).toBe('number'); + expect(typeof data.timestamp).toBe('string'); + expect(data.totalSize).toBeGreaterThan(0); + expect(data.sizeKB).toBeGreaterThan(0); + } + } + }); +}); diff --git a/bundle-baseline.json b/bundle-baseline.json new file mode 100644 index 0000000..de4820f --- /dev/null +++ b/bundle-baseline.json @@ -0,0 +1,37 @@ +{ + "address-validator": { + "totalSize": 102400, + "sizeKB": 100, + "timestamp": "2026-09-25T00:00:00.000Z" + }, + "balance-viewer": { + "totalSize": 122880, + "sizeKB": 120, + "timestamp": "2026-09-25T00:00:00.000Z" + }, + "trustline-checker": { + "totalSize": 92160, + "sizeKB": 90, + "timestamp": "2026-09-25T00:00:00.000Z" + }, + "payment-qr": { + "totalSize": 153600, + "sizeKB": 150, + "timestamp": "2026-09-25T00:00:00.000Z" + }, + "transaction-lookup": { + "totalSize": 112640, + "sizeKB": 110, + "timestamp": "2026-09-25T00:00:00.000Z" + }, + "freighter-connect": { + "totalSize": 71680, + "sizeKB": 70, + "timestamp": "2026-09-25T00:00:00.000Z" + }, + "testnet-faucet": { + "totalSize": 81920, + "sizeKB": 80, + "timestamp": "2026-09-25T00:00:00.000Z" + } +} diff --git a/core/auth/__tests__/permissions.test.ts b/core/auth/__tests__/permissions.test.ts new file mode 100644 index 0000000..fe8d728 --- /dev/null +++ b/core/auth/__tests__/permissions.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { hasPermission, hasAnyPermission, hasAllPermissions, ROLE_PERMISSIONS } from '../types'; +import { requirePermission, UnauthorizedError, getAuthContext } from '../middleware'; + +describe('Role-based access control', () => { + describe('hasPermission', () => { + it('allows viewers to access public tools', () => { + expect(hasPermission('viewer', 'view:tools')).toBe(true); + expect(hasPermission('viewer', 'view:dashboard')).toBe(true); + }); + + it('denies viewers from accessing admin functions', () => { + expect(hasPermission('viewer', 'admin:health')).toBe(false); + expect(hasPermission('viewer', 'admin:recovery')).toBe(false); + expect(hasPermission('viewer', 'admin:metrics')).toBe(false); + }); + + it('allows maintainers to access all functions', () => { + expect(hasPermission('maintainer', 'view:tools')).toBe(true); + expect(hasPermission('maintainer', 'view:dashboard')).toBe(true); + expect(hasPermission('maintainer', 'admin:health')).toBe(true); + expect(hasPermission('maintainer', 'admin:recovery')).toBe(true); + expect(hasPermission('maintainer', 'admin:metrics')).toBe(true); + }); + + it('allows service accounts limited admin access', () => { + expect(hasPermission('service', 'admin:health')).toBe(true); + expect(hasPermission('service', 'admin:metrics')).toBe(true); + expect(hasPermission('service', 'view:tools')).toBe(false); + expect(hasPermission('service', 'admin:recovery')).toBe(false); + }); + }); + + describe('hasAnyPermission', () => { + it('returns true if user has at least one permission', () => { + expect(hasAnyPermission('viewer', ['view:tools', 'admin:health'])).toBe(true); + expect(hasAnyPermission('service', ['view:tools', 'admin:health'])).toBe(true); + }); + + it('returns false if user has none of the permissions', () => { + expect(hasAnyPermission('viewer', ['admin:health', 'admin:recovery'])).toBe(false); + }); + }); + + describe('hasAllPermissions', () => { + it('returns true only if user has all permissions', () => { + expect(hasAllPermissions('maintainer', ['view:tools', 'admin:health'])).toBe(true); + expect(hasAllPermissions('viewer', ['view:tools', 'view:dashboard'])).toBe(true); + }); + + it('returns false if user lacks any permission', () => { + expect(hasAllPermissions('viewer', ['view:tools', 'admin:health'])).toBe(false); + }); + }); + + describe('requirePermission', () => { + it('allows requests with sufficient permissions', () => { + expect(() => + requirePermission({ role: 'viewer' }, 'view:tools') + ).not.toThrow(); + }); + + it('throws UnauthorizedError when permission is denied', () => { + expect(() => + requirePermission({ role: 'viewer' }, 'admin:health') + ).toThrow(UnauthorizedError); + }); + + it('includes context in error', () => { + try { + requirePermission({ role: 'viewer' }, 'admin:recovery'); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(UnauthorizedError); + const authError = error as UnauthorizedError; + expect(authError.requiredPermission).toBe('admin:recovery'); + expect(authError.userRole).toBe('viewer'); + } + }); + }); + + describe('getAuthContext', () => { + it('defaults to viewer role', () => { + const context = getAuthContext(); + expect(context.role).toBe('viewer'); + }); + }); + + describe('ROLE_PERMISSIONS coverage', () => { + it('defines permissions for all roles', () => { + expect(ROLE_PERMISSIONS.viewer).toBeDefined(); + expect(ROLE_PERMISSIONS.maintainer).toBeDefined(); + expect(ROLE_PERMISSIONS.service).toBeDefined(); + }); + + it('maintains permission hierarchy', () => { + const viewerPerms = ROLE_PERMISSIONS.viewer; + const maintainerPerms = ROLE_PERMISSIONS.maintainer; + + viewerPerms.forEach(perm => { + expect(maintainerPerms).toContain(perm); + }); + }); + }); +}); diff --git a/core/auth/middleware.ts b/core/auth/middleware.ts new file mode 100644 index 0000000..0d07888 --- /dev/null +++ b/core/auth/middleware.ts @@ -0,0 +1,64 @@ +/** + * Server-side authorization middleware + * + * Enforces permission checks for API routes and server actions. + * These checks run server-side and cannot be bypassed through UI manipulation. + */ + +import { Role, Permission, hasPermission } from './types'; + +export class UnauthorizedError extends Error { + constructor( + message: string, + public readonly requiredPermission: Permission, + public readonly userRole: Role + ) { + super(message); + this.name = 'UnauthorizedError'; + } +} + +export interface AuthContext { + role: Role; + userId?: string; +} + +/** + * Verify that the current user has the required permission. + * Throws UnauthorizedError if permission is denied. + */ +export function requirePermission( + context: AuthContext, + permission: Permission +): void { + if (!hasPermission(context.role, permission)) { + throw new UnauthorizedError( + `Permission denied: ${permission} requires elevated privileges`, + permission, + context.role + ); + } +} + +/** + * Get the current auth context from request headers or environment. + * For Utix, this defaults to viewer role since all tools are read-only. + */ +export function getAuthContext(): AuthContext { + const role = (process.env.UTIX_USER_ROLE as Role) || 'viewer'; + return { role }; +} + +/** + * Higher-order function that wraps server actions with permission checks. + */ +export function withPermission any>( + permission: Permission, + handler: T +): T { + return (async (...args: Parameters) => { + const context = getAuthContext(); + requirePermission(context, permission); + return handler(...args); + }) as T; +} diff --git a/core/auth/types.ts b/core/auth/types.ts new file mode 100644 index 0000000..7ba5260 --- /dev/null +++ b/core/auth/types.ts @@ -0,0 +1,38 @@ +/** + * Role-based access control types for Utix + * + * Utix is read-only by design and does not perform privileged operations. + * These types establish a foundation for future access controls if needed. + */ + +export type Role = 'viewer' | 'maintainer' | 'service'; + +export type Permission = + | 'view:tools' + | 'view:dashboard' + | 'admin:health' + | 'admin:recovery' + | 'admin:metrics'; + +export interface RolePermissions { + role: Role; + permissions: Permission[]; +} + +export const ROLE_PERMISSIONS: Record = { + viewer: ['view:tools', 'view:dashboard'], + maintainer: ['view:tools', 'view:dashboard', 'admin:health', 'admin:recovery', 'admin:metrics'], + service: ['admin:health', 'admin:metrics'], +}; + +export function hasPermission(role: Role, permission: Permission): boolean { + return ROLE_PERMISSIONS[role].includes(permission); +} + +export function hasAnyPermission(role: Role, permissions: Permission[]): boolean { + return permissions.some(p => hasPermission(role, p)); +} + +export function hasAllPermissions(role: Role, permissions: Permission[]): boolean { + return permissions.every(p => hasPermission(role, p)); +} diff --git a/core/health/__tests__/monitor.test.ts b/core/health/__tests__/monitor.test.ts new file mode 100644 index 0000000..4216d89 --- /dev/null +++ b/core/health/__tests__/monitor.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest'; +import { healthMonitor, formatHealthReport } from '../monitor'; +import { HealthSummary } from '../types'; + +describe('Health monitor', () => { + describe('checkHealth', () => { + it('returns health summary with all components', async () => { + const summary = await healthMonitor.checkHealth(); + + expect(summary.timestamp).toBeInstanceOf(Date); + expect(summary.overallStatus).toMatch(/^(healthy|degraded|unhealthy)$/); + expect(Array.isArray(summary.indicators)).toBe(true); + expect(Array.isArray(summary.unresolvedExceptions)).toBe(true); + expect(Array.isArray(summary.staleRecords)).toBe(true); + expect(summary.metrics).toBeDefined(); + }); + + it('includes feature registry health check', async () => { + const summary = await healthMonitor.checkHealth(); + + const registryIndicator = summary.indicators.find( + i => i.name === 'Feature Registry' + ); + + expect(registryIndicator).toBeDefined(); + expect(registryIndicator?.status).toBeDefined(); + expect(registryIndicator?.message).toBeTruthy(); + expect(registryIndicator?.lastChecked).toBeInstanceOf(Date); + }); + + it('includes network connectivity checks', async () => { + const summary = await healthMonitor.checkHealth(); + + const networkIndicators = summary.indicators.filter( + i => i.name.startsWith('Network:') + ); + + expect(networkIndicators.length).toBeGreaterThan(0); + }); + + it('includes environment configuration check', async () => { + const summary = await healthMonitor.checkHealth(); + + const envIndicator = summary.indicators.find( + i => i.name === 'Environment Configuration' + ); + + expect(envIndicator).toBeDefined(); + }); + + it('computes correct metrics', async () => { + const summary = await healthMonitor.checkHealth(); + + expect(summary.metrics.totalFeatures).toBeGreaterThanOrEqual(0); + expect(summary.metrics.healthyFeatures).toBeLessThanOrEqual(summary.metrics.totalFeatures); + expect(summary.metrics.unresolvedExceptionCount).toBe(summary.unresolvedExceptions.length); + expect(summary.metrics.staleRecordCount).toBe(summary.staleRecords.length); + }); + }); + + describe('recordException', () => { + it('records unresolved exceptions', () => { + healthMonitor.recordException({ + type: 'TestError', + message: 'Test exception', + context: { key: 'value' }, + }); + + const exceptions = healthMonitor.getUnresolvedExceptions(); + const testException = exceptions.find(e => e.type === 'TestError'); + + expect(testException).toBeDefined(); + expect(testException?.message).toBe('Test exception'); + expect(testException?.resolved).toBe(false); + }); + }); + + describe('recordStaleRecord', () => { + it('records stale records with computed age', () => { + const lastActivity = new Date(Date.now() - 1000 * 60 * 60 * 24 * 7); + + healthMonitor.recordStaleRecord({ + id: 'test-record', + type: 'TestType', + lastActivity, + details: { key: 'value' }, + }); + + const staleRecords = healthMonitor.getStaleRecords(0); + const testRecord = staleRecords.find(r => r.id === 'test-record'); + + expect(testRecord).toBeDefined(); + expect(testRecord?.age).toBeGreaterThan(0); + }); + + it('filters stale records by max age', () => { + const recentActivity = new Date(Date.now() - 1000 * 60); + const oldActivity = new Date(Date.now() - 1000 * 60 * 60 * 24 * 7); + + healthMonitor.recordStaleRecord({ + id: 'recent', + type: 'Test', + lastActivity: recentActivity, + details: {}, + }); + + healthMonitor.recordStaleRecord({ + id: 'old', + type: 'Test', + lastActivity: oldActivity, + details: {}, + }); + + const maxAge = 1000 * 60 * 60 * 24; + const oldRecords = healthMonitor.getStaleRecords(maxAge); + + expect(oldRecords.some(r => r.id === 'old')).toBe(true); + expect(oldRecords.some(r => r.id === 'recent')).toBe(false); + }); + }); + + describe('formatHealthReport', () => { + it('formats healthy system report', () => { + const summary: HealthSummary = { + timestamp: new Date('2026-01-01T00:00:00Z'), + overallStatus: 'healthy', + indicators: [ + { + name: 'Test Indicator', + status: 'healthy', + message: 'All good', + lastChecked: new Date(), + }, + ], + unresolvedExceptions: [], + staleRecords: [], + metrics: { + totalFeatures: 10, + healthyFeatures: 10, + unresolvedExceptionCount: 0, + staleRecordCount: 0, + }, + }; + + const report = formatHealthReport(summary); + + expect(report).toContain('OPERATIONAL HEALTH DASHBOARD'); + expect(report).toContain('HEALTHY'); + expect(report).toContain('10/10 healthy'); + expect(report).toContain('✓ Test Indicator: All good'); + }); + + it('includes exceptions in report', () => { + const summary: HealthSummary = { + timestamp: new Date(), + overallStatus: 'degraded', + indicators: [], + unresolvedExceptions: [ + { + id: 'exc-123', + timestamp: new Date(), + type: 'TestError', + message: 'Something broke', + resolved: false, + }, + ], + staleRecords: [], + metrics: { + totalFeatures: 10, + healthyFeatures: 9, + unresolvedExceptionCount: 1, + staleRecordCount: 0, + }, + }; + + const report = formatHealthReport(summary); + + expect(report).toContain('UNRESOLVED EXCEPTIONS'); + expect(report).toContain('TestError'); + expect(report).toContain('Something broke'); + }); + + it('includes stale records in report', () => { + const summary: HealthSummary = { + timestamp: new Date(), + overallStatus: 'degraded', + indicators: [], + unresolvedExceptions: [], + staleRecords: [ + { + id: 'stale-123', + type: 'StaleType', + age: 1000 * 60 * 60 * 24 * 7, + lastActivity: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), + details: {}, + }, + ], + metrics: { + totalFeatures: 10, + healthyFeatures: 10, + unresolvedExceptionCount: 0, + staleRecordCount: 1, + }, + }; + + const report = formatHealthReport(summary); + + expect(report).toContain('STALE RECORDS'); + expect(report).toContain('StaleType'); + expect(report).toContain('7 days old'); + }); + }); +}); diff --git a/core/health/monitor.ts b/core/health/monitor.ts new file mode 100644 index 0000000..d2838cf --- /dev/null +++ b/core/health/monitor.ts @@ -0,0 +1,251 @@ +/** + * Operational health monitoring for maintainers + * + * Aggregates health indicators, unresolved exceptions, and stale records + * to provide actionable operational insights. + */ + +import { HealthIndicator, HealthStatus, HealthSummary, UnresolvedException, StaleRecord } from './types'; + +class HealthMonitor { + private indicators: HealthIndicator[] = []; + private exceptions: UnresolvedException[] = []; + private staleRecords: StaleRecord[] = []; + + async checkHealth(): Promise { + await this.runHealthChecks(); + + const unresolvedExceptions = this.exceptions.filter(e => !e.resolved); + + const overallStatus = this.computeOverallStatus(); + + const healthyFeatures = this.indicators.filter(i => i.status === 'healthy').length; + + return { + timestamp: new Date(), + overallStatus, + indicators: this.indicators, + unresolvedExceptions, + staleRecords: this.staleRecords, + metrics: { + totalFeatures: this.indicators.length, + healthyFeatures, + unresolvedExceptionCount: unresolvedExceptions.length, + staleRecordCount: this.staleRecords.length, + }, + }; + } + + private async runHealthChecks(): Promise { + this.indicators = []; + + await this.checkFeatureRegistry(); + await this.checkNetworkConnectivity(); + await this.checkEnvironmentConfiguration(); + } + + private async checkFeatureRegistry(): Promise { + try { + const { registry } = await import('../registry/registry'); + const manifests = Object.values(registry); + + const status: HealthStatus = manifests.length > 0 ? 'healthy' : 'unhealthy'; + + this.indicators.push({ + name: 'Feature Registry', + status, + message: `${manifests.length} features registered`, + details: { + featureCount: manifests.length, + features: manifests.map(m => m.slug), + }, + lastChecked: new Date(), + }); + } catch (error) { + this.indicators.push({ + name: 'Feature Registry', + status: 'unhealthy', + message: 'Failed to load feature registry', + details: { error: error instanceof Error ? error.message : String(error) }, + lastChecked: new Date(), + }); + + this.recordException({ + type: 'RegistryLoadError', + message: 'Failed to load feature registry', + error, + }); + } + } + + private async checkNetworkConnectivity(): Promise { + const endpoints = [ + { name: 'Horizon Testnet', url: process.env.NEXT_PUBLIC_HORIZON_TESTNET_URL }, + { name: 'Horizon Mainnet', url: process.env.NEXT_PUBLIC_HORIZON_MAINNET_URL }, + ]; + + for (const endpoint of endpoints) { + if (!endpoint.url) { + this.indicators.push({ + name: `Network: ${endpoint.name}`, + status: 'degraded', + message: 'Endpoint not configured', + lastChecked: new Date(), + }); + continue; + } + + try { + new URL(endpoint.url); + + this.indicators.push({ + name: `Network: ${endpoint.name}`, + status: 'healthy', + message: 'Endpoint configured', + details: { url: endpoint.url }, + lastChecked: new Date(), + }); + } catch { + this.indicators.push({ + name: `Network: ${endpoint.name}`, + status: 'unhealthy', + message: 'Invalid endpoint URL', + details: { url: endpoint.url }, + lastChecked: new Date(), + }); + } + } + } + + private async checkEnvironmentConfiguration(): Promise { + const requiredVars = [ + 'NEXT_PUBLIC_STELLAR_NETWORK', + 'NEXT_PUBLIC_HORIZON_TESTNET_URL', + 'NEXT_PUBLIC_HORIZON_MAINNET_URL', + ]; + + const missing = requiredVars.filter(v => !process.env[v]); + + if (missing.length === 0) { + this.indicators.push({ + name: 'Environment Configuration', + status: 'healthy', + message: 'All required variables configured', + lastChecked: new Date(), + }); + } else { + this.indicators.push({ + name: 'Environment Configuration', + status: 'degraded', + message: `${missing.length} required variable(s) not set`, + details: { missing }, + lastChecked: new Date(), + }); + } + } + + private computeOverallStatus(): HealthStatus { + if (this.indicators.some(i => i.status === 'unhealthy')) { + return 'unhealthy'; + } + if (this.indicators.some(i => i.status === 'degraded')) { + return 'degraded'; + } + return 'healthy'; + } + + recordException(params: { + type: string; + message: string; + error?: unknown; + context?: Record; + }): void { + this.exceptions.push({ + id: `exc-${Date.now()}-${Math.random().toString(36).slice(2)}`, + timestamp: new Date(), + type: params.type, + message: params.message, + stack: params.error instanceof Error ? params.error.stack : undefined, + context: params.context, + resolved: false, + }); + } + + recordStaleRecord(record: Omit): void { + const age = Date.now() - record.lastActivity.getTime(); + this.staleRecords.push({ ...record, age }); + } + + getUnresolvedExceptions(): UnresolvedException[] { + return this.exceptions.filter(e => !e.resolved); + } + + getStaleRecords(maxAgeMs: number): StaleRecord[] { + return this.staleRecords.filter(r => r.age > maxAgeMs); + } +} + +export const healthMonitor = new HealthMonitor(); + +export function formatHealthReport(summary: HealthSummary): string { + const lines: string[] = []; + + lines.push('='.repeat(70)); + lines.push('OPERATIONAL HEALTH DASHBOARD'); + lines.push('='.repeat(70)); + lines.push(''); + lines.push(`Timestamp: ${summary.timestamp.toISOString()}`); + lines.push(`Overall Status: ${summary.overallStatus.toUpperCase()}`); + lines.push(''); + + lines.push('METRICS:'); + lines.push(` Features: ${summary.metrics.healthyFeatures}/${summary.metrics.totalFeatures} healthy`); + lines.push(` Unresolved Exceptions: ${summary.metrics.unresolvedExceptionCount}`); + lines.push(` Stale Records: ${summary.metrics.staleRecordCount}`); + lines.push(''); + + lines.push('HEALTH INDICATORS:'); + for (const indicator of summary.indicators) { + const statusSymbol = { + healthy: '✓', + degraded: '!', + unhealthy: '✗', + }[indicator.status]; + + lines.push(` ${statusSymbol} ${indicator.name}: ${indicator.message}`); + } + lines.push(''); + + if (summary.unresolvedExceptions.length > 0) { + lines.push(`UNRESOLVED EXCEPTIONS (${summary.unresolvedExceptions.length}):`); + summary.unresolvedExceptions.slice(0, 10).forEach((exc, i) => { + lines.push(` ${i + 1}. [${exc.type}] ${exc.message}`); + lines.push(` Timestamp: ${exc.timestamp.toISOString()}`); + lines.push(` ID: ${exc.id}`); + }); + + if (summary.unresolvedExceptions.length > 10) { + lines.push(` ... and ${summary.unresolvedExceptions.length - 10} more`); + } + lines.push(''); + } + + if (summary.staleRecords.length > 0) { + lines.push(`STALE RECORDS (${summary.staleRecords.length}):`); + summary.staleRecords.slice(0, 10).forEach((record, i) => { + const ageDays = Math.floor(record.age / (1000 * 60 * 60 * 24)); + lines.push(` ${i + 1}. ${record.type} (${ageDays} days old)`); + lines.push(` ID: ${record.id}`); + lines.push(` Last Activity: ${record.lastActivity.toISOString()}`); + }); + + if (summary.staleRecords.length > 10) { + lines.push(` ... and ${summary.staleRecords.length - 10} more`); + } + lines.push(''); + } + + lines.push('='.repeat(70)); + + return lines.join('\n'); +} diff --git a/core/health/types.ts b/core/health/types.ts new file mode 100644 index 0000000..fdb2e5a --- /dev/null +++ b/core/health/types.ts @@ -0,0 +1,45 @@ +/** + * Types for operational health monitoring + */ + +export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy'; + +export interface HealthIndicator { + name: string; + status: HealthStatus; + message: string; + details?: Record; + lastChecked: Date; +} + +export interface UnresolvedException { + id: string; + timestamp: Date; + type: string; + message: string; + stack?: string; + context?: Record; + resolved: boolean; +} + +export interface StaleRecord { + id: string; + type: string; + age: number; + lastActivity: Date; + details: Record; +} + +export interface HealthSummary { + timestamp: Date; + overallStatus: HealthStatus; + indicators: HealthIndicator[]; + unresolvedExceptions: UnresolvedException[]; + staleRecords: StaleRecord[]; + metrics: { + totalFeatures: number; + healthyFeatures: number; + unresolvedExceptionCount: number; + staleRecordCount: number; + }; +} diff --git a/core/recovery/__tests__/validator.test.ts b/core/recovery/__tests__/validator.test.ts new file mode 100644 index 0000000..e184d25 --- /dev/null +++ b/core/recovery/__tests__/validator.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { validator, formatValidationReport } from '../validator'; +import { InvariantViolation } from '../types'; + +describe('Recovery validator', () => { + describe('validate', () => { + it('runs all registered checks', async () => { + const result = await validator.validate(); + + expect(result).toBeDefined(); + expect(result.timestamp).toBeInstanceOf(Date); + expect(result.checks.total).toBeGreaterThan(0); + }); + + it('reports passed when no violations found', async () => { + const result = await validator.validate(); + + if (result.violations.length === 0) { + expect(result.passed).toBe(true); + expect(result.checks.passed).toBe(result.checks.total); + expect(result.checks.failed).toBe(0); + } + }); + + it('includes violation details when checks fail', async () => { + const result = await validator.validate(); + + result.violations.forEach(violation => { + expect(violation.type).toBeDefined(); + expect(violation.severity).toMatch(/^(critical|warning)$/); + expect(violation.message).toBeTruthy(); + expect(violation.details).toBeDefined(); + expect(Array.isArray(violation.affectedRecords)).toBe(true); + }); + }); + }); + + describe('formatValidationReport', () => { + it('formats passed validation report', () => { + const result = { + timestamp: new Date('2026-01-01T00:00:00Z'), + passed: true, + violations: [], + checks: { total: 5, passed: 5, failed: 0 }, + }; + + const report = formatValidationReport(result); + + expect(report).toContain('PASSED'); + expect(report).toContain('5 passed, 0 failed, 5 total'); + expect(report).toContain('All invariants validated successfully'); + }); + + it('formats failed validation report with violations', () => { + const violations: InvariantViolation[] = [ + { + type: 'missing-record', + severity: 'critical', + message: 'Test violation', + details: { key: 'value' }, + affectedRecords: ['record1'], + }, + { + type: 'inconsistent-state', + severity: 'warning', + message: 'Test warning', + details: {}, + affectedRecords: [], + }, + ]; + + const result = { + timestamp: new Date('2026-01-01T00:00:00Z'), + passed: false, + violations, + checks: { total: 5, passed: 3, failed: 2 }, + }; + + const report = formatValidationReport(result); + + expect(report).toContain('FAILED'); + expect(report).toContain('3 passed, 2 failed, 5 total'); + expect(report).toContain('CRITICAL'); + expect(report).toContain('WARNINGS'); + expect(report).toContain('Test violation'); + expect(report).toContain('Test warning'); + }); + }); + + describe('registered checks', () => { + it('includes registry manifest consistency check', async () => { + const checks = validator.getRegisteredChecks(); + const registryCheck = checks.find(c => c.name === 'registry-manifest-consistency'); + + expect(registryCheck).toBeDefined(); + expect(registryCheck?.description).toBeTruthy(); + }); + + it('includes network configuration validity check', async () => { + const checks = validator.getRegisteredChecks(); + const networkCheck = checks.find(c => c.name === 'network-configuration-validity'); + + expect(networkCheck).toBeDefined(); + expect(networkCheck?.description).toBeTruthy(); + }); + + it('all checks are executable', async () => { + const checks = validator.getRegisteredChecks(); + + for (const check of checks) { + const violations = await check.check(); + expect(Array.isArray(violations)).toBe(true); + } + }); + }); +}); diff --git a/core/recovery/types.ts b/core/recovery/types.ts new file mode 100644 index 0000000..651cef0 --- /dev/null +++ b/core/recovery/types.ts @@ -0,0 +1,35 @@ +/** + * Types for disaster recovery validation + */ + +export type InvariantType = + | 'missing-record' + | 'orphaned-reference' + | 'duplicate-record' + | 'inconsistent-state' + | 'invalid-reference'; + +export interface InvariantViolation { + type: InvariantType; + severity: 'critical' | 'warning'; + message: string; + details: Record; + affectedRecords: string[]; +} + +export interface ValidationResult { + timestamp: Date; + passed: boolean; + violations: InvariantViolation[]; + checks: { + total: number; + passed: number; + failed: number; + }; +} + +export interface InvariantCheck { + name: string; + description: string; + check: () => Promise; +} diff --git a/core/recovery/validator.ts b/core/recovery/validator.ts new file mode 100644 index 0000000..369bc26 --- /dev/null +++ b/core/recovery/validator.ts @@ -0,0 +1,209 @@ +/** + * Disaster recovery validation for core domain invariants + * + * This module provides read-only validation checks to verify data integrity + * after restore or migration operations. + */ + +import { InvariantCheck, InvariantViolation, ValidationResult } from './types'; + +class RecoveryValidator { + private checks: InvariantCheck[] = []; + + registerCheck(check: InvariantCheck): void { + this.checks.push(check); + } + + async validate(): Promise { + const timestamp = new Date(); + const violations: InvariantViolation[] = []; + let passedChecks = 0; + let failedChecks = 0; + + for (const check of this.checks) { + try { + const checkViolations = await check.check(); + + if (checkViolations.length === 0) { + passedChecks++; + } else { + failedChecks++; + violations.push(...checkViolations); + } + } catch (error) { + failedChecks++; + violations.push({ + type: 'inconsistent-state', + severity: 'critical', + message: `Check "${check.name}" failed with error`, + details: { + checkName: check.name, + error: error instanceof Error ? error.message : String(error), + }, + affectedRecords: [], + }); + } + } + + return { + timestamp, + passed: violations.length === 0, + violations, + checks: { + total: this.checks.length, + passed: passedChecks, + failed: failedChecks, + }, + }; + } + + getRegisteredChecks(): readonly InvariantCheck[] { + return [...this.checks]; + } +} + +export const validator = new RecoveryValidator(); + +// Core invariant checks for Utix + +validator.registerCheck({ + name: 'registry-manifest-consistency', + description: 'Verify all feature manifests are valid and consistent', + check: async () => { + const violations: InvariantViolation[] = []; + + try { + const { registry } = await import('../registry/registry'); + const manifests = Object.values(registry); + + const slugs = new Set(); + + for (const manifest of manifests) { + if (slugs.has(manifest.slug)) { + violations.push({ + type: 'duplicate-record', + severity: 'critical', + message: `Duplicate feature slug detected: ${manifest.slug}`, + details: { slug: manifest.slug }, + affectedRecords: [manifest.slug], + }); + } + slugs.add(manifest.slug); + + if (!manifest.title || !manifest.description) { + violations.push({ + type: 'inconsistent-state', + severity: 'warning', + message: `Feature ${manifest.slug} has incomplete metadata`, + details: { + slug: manifest.slug, + hasTitle: !!manifest.title, + hasDescription: !!manifest.description, + }, + affectedRecords: [manifest.slug], + }); + } + } + } catch (error) { + violations.push({ + type: 'missing-record', + severity: 'critical', + message: 'Failed to load feature registry', + details: { error: error instanceof Error ? error.message : String(error) }, + affectedRecords: [], + }); + } + + return violations; + }, +}); + +validator.registerCheck({ + name: 'network-configuration-validity', + description: 'Verify network configuration is complete and valid', + check: async () => { + const violations: InvariantViolation[] = []; + + const requiredConfigs = [ + 'NEXT_PUBLIC_HORIZON_TESTNET_URL', + 'NEXT_PUBLIC_HORIZON_MAINNET_URL', + ]; + + for (const config of requiredConfigs) { + const value = process.env[config]; + if (!value) { + violations.push({ + type: 'missing-record', + severity: 'warning', + message: `Missing network configuration: ${config}`, + details: { configKey: config }, + affectedRecords: [config], + }); + } else { + try { + new URL(value); + } catch { + violations.push({ + type: 'invalid-reference', + severity: 'critical', + message: `Invalid URL in network configuration: ${config}`, + details: { configKey: config, value }, + affectedRecords: [config], + }); + } + } + } + + return violations; + }, +}); + +export function formatValidationReport(result: ValidationResult): string { + const lines: string[] = []; + + lines.push('='.repeat(70)); + lines.push('DISASTER RECOVERY VALIDATION REPORT'); + lines.push('='.repeat(70)); + lines.push(''); + lines.push(`Timestamp: ${result.timestamp.toISOString()}`); + lines.push(`Status: ${result.passed ? 'PASSED' : 'FAILED'}`); + lines.push(''); + lines.push(`Checks: ${result.checks.passed} passed, ${result.checks.failed} failed, ${result.checks.total} total`); + lines.push(''); + + if (result.violations.length === 0) { + lines.push('All invariants validated successfully.'); + } else { + lines.push(`Found ${result.violations.length} violation(s):`); + lines.push(''); + + const critical = result.violations.filter(v => v.severity === 'critical'); + const warnings = result.violations.filter(v => v.severity === 'warning'); + + if (critical.length > 0) { + lines.push(`CRITICAL (${critical.length}):`); + critical.forEach((v, i) => { + lines.push(` ${i + 1}. [${v.type}] ${v.message}`); + if (v.affectedRecords.length > 0) { + lines.push(` Affected: ${v.affectedRecords.join(', ')}`); + } + }); + lines.push(''); + } + + if (warnings.length > 0) { + lines.push(`WARNINGS (${warnings.length}):`); + warnings.forEach((v, i) => { + lines.push(` ${i + 1}. [${v.type}] ${v.message}`); + if (v.affectedRecords.length > 0) { + lines.push(` Affected: ${v.affectedRecords.join(', ')}`); + } + }); + lines.push(''); + } + } + + lines.push('='.repeat(70)); + + return lines.join('\n'); +} diff --git a/docs/BUNDLE_BUDGET.md b/docs/BUNDLE_BUDGET.md new file mode 100644 index 0000000..65847e7 --- /dev/null +++ b/docs/BUNDLE_BUDGET.md @@ -0,0 +1,193 @@ +# Bundle Size Budget Enforcement + +Utix enforces per-feature-slice bundle size budgets to prevent any single tool from quietly pulling in heavy dependencies that degrade load performance. + +## Why Per-Feature Budgets + +With an architecture built around independent, lazy-loaded feature slices, a generic total bundle size budget does not work. Each tool loads only its own chunk, so we need per-slice budgets that: + +1. Prevent one heavy tool from hiding behind another's budget +2. Correctly attribute shared/core code versus slice-specific code +3. Allow independent contributors to add features without global coordination +4. Catch dependency bloat at PR time, not after user complaints + +## Default Budget + +**150 KB per feature slice** (uncompressed JavaScript) + +This covers typical tool implementations including form validation, API calls, formatting, and result display. + +## Feature-Specific Budgets + +Some tools have adjusted budgets based on their requirements: + +| Feature | Budget | Justification | +| --- | --- | --- | +| payment-qr | 180 KB | QR code generation library | +| address-validator | 120 KB | Pure validation logic, no heavy deps | +| balance-viewer | 150 KB | Default budget | +| trustline-checker | 100 KB | Simple lookup operation | +| transaction-lookup | 140 KB | Moderate API interaction | +| freighter-connect | 80 KB | Minimal wallet detection | +| testnet-faucet | 90 KB | Single API call | + +## Running Budget Checks + +### Local Check + +```bash +npm run build +node scripts/bundle-budget.mjs +``` + +### In CI + +The check runs automatically as part of the build process. Budget violations fail the CI pipeline. + +### JSON Output + +For integration with tooling: + +```bash +node scripts/bundle-budget.mjs --json +``` + +## Understanding Results + +Example output: + +``` +Feature Size Budget Status +-------------------------------------------------------------------------------- +payment-qr 165 KB 180 KB OK +address-validator 118 KB 120 KB OK +balance-viewer 155 KB 150 KB EXCEEDED +``` + +**Status values:** +- `OK` - Within budget +- `EXCEEDED` - Over budget, PR will fail + +## Baseline Tracking + +The `bundle-baseline.json` file tracks historical sizes to detect regressions: + +```json +{ + "address-validator": { + "totalSize": 102400, + "sizeKB": 100, + "timestamp": "2026-09-25T00:00:00.000Z" + } +} +``` + +### Updating the Baseline + +After intentional changes that affect bundle size: + +```bash +npm run build +node scripts/bundle-budget.mjs --update-baseline +``` + +Commit the updated baseline with your PR. + +## What Gets Measured + +### Included +- Feature slice implementation code +- Feature-specific dependencies +- Lazy-loaded chunks for the feature route + +### Excluded +- Shared core modules (`core/*`) +- Next.js runtime and framework code +- Vendor chunks shared across all features +- Static assets (CSS, images) + +This ensures each feature is only charged for its incremental cost. + +## When Budget Is Exceeded + +If your feature exceeds its budget: + +1. **Review dependencies** - Did you add a heavy library? + - Check package size on npm or bundlephobia.com + - Consider lighter alternatives + - Use dynamic imports for optional functionality + +2. **Code-split heavy operations** + - Move heavy computations to web workers + - Lazy load rarely-used components + - Split large utility functions + +3. **Optimize imports** + - Import only what you need: `import { specific } from 'lib'` + - Avoid entire libraries: `import * as lib from 'lib'` + - Check for duplicate dependencies in package-lock.json + +4. **Request budget increase** + - If the feature genuinely requires more budget + - Document why in the PR description + - Update `FEATURE_BUDGETS` in `scripts/bundle-budget.mjs` + - Update this documentation + +## Adding New Features + +New features inherit the default budget (150 KB) unless specified otherwise. + +To set a custom budget: + +1. Edit `scripts/bundle-budget.mjs` +2. Add entry to `FEATURE_BUDGETS` object: + ```javascript + const FEATURE_BUDGETS = { + 'your-feature': 120, // KB + }; + ``` +3. Document the justification in your PR + +## CI Integration + +The bundle budget check runs after build: + +```yaml +- name: Check bundle budgets + run: | + npm run build + node scripts/bundle-budget.mjs +``` + +Exit codes: +- **0** - All features within budget +- **1** - Budget violations detected +- **2** - Script error (missing build output, etc.) + +## Testing Budget Enforcement + +To verify the budget system catches violations: + +1. Add a deliberately heavy dependency to a test feature +2. Run `npm run build && node scripts/bundle-budget.mjs` +3. Confirm the check fails with `EXCEEDED` status +4. Remove the dependency + +## Budget Evolution + +As the project grows: + +- Monitor baseline drift over time +- Review whether default budget remains appropriate +- Consider stricter budgets for simple tools +- Document rationale for budget increases +- Keep budgets tight enough to prevent bloat, loose enough to allow reasonable implementations + +## Best Practices + +- Check bundle size before requesting review +- Update baseline only when intentional +- Never increase budget to bypass review +- Document heavy dependencies in PR description +- Consider performance impact on slow connections +- Remember: users on mobile networks pay for every KB diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md new file mode 100644 index 0000000..3570b1c --- /dev/null +++ b/docs/DISASTER_RECOVERY.md @@ -0,0 +1,134 @@ +# Disaster Recovery Validation + +After restore or migration operations, maintainers must validate core domain invariants to ensure data consistency and system integrity. + +## Quick Start + +```bash +npm run recovery:validate +``` + +This command runs read-only validation checks and reports any violations found. + +## What Gets Validated + +The validation script checks for: + +1. **Missing records** - Required data that should exist but is absent +2. **Orphaned references** - References pointing to non-existent records +3. **Duplicate records** - Records with identical unique identifiers +4. **Inconsistent state** - Data that violates business rules +5. **Invalid references** - Malformed or incorrect reference data + +## Current Checks + +### Registry Manifest Consistency +Verifies that all feature manifests are valid, properly structured, and have unique slugs. + +### Network Configuration Validity +Ensures all required network endpoints are configured and URLs are well-formed. + +## Output Formats + +### Human-readable (default) + +```bash +npm run recovery:validate +``` + +Produces a formatted report suitable for reading. + +### JSON output + +```bash +npm run recovery:validate -- --json +``` + +Produces machine-readable JSON for integration with monitoring systems. + +## Exit Codes + +- **0** - All checks passed +- **1** - One or more checks failed +- **2** - Validation script error + +## Understanding Violations + +### Severity Levels + +**Critical** +Must be resolved immediately. Indicates data corruption or serious integrity violation. + +**Warning** +Should be investigated but may not require immediate action. Indicates potential issues. + +### Violation Types + +Each violation includes: +- Type and severity +- Descriptive message +- Relevant details +- List of affected records + +## Escalation Process + +When validation fails: + +1. Review the full report and identify critical violations +2. Document all violations with timestamps and context +3. For critical violations, halt deployment and investigate immediately +4. For warnings, create tracking issues and schedule resolution +5. After fixes, re-run validation before proceeding +6. Keep validation reports for audit trail + +## Integration with CI/CD + +The validation command can be integrated into deployment pipelines: + +```yaml +- name: Validate recovery + run: npm run recovery:validate +``` + +The non-zero exit code will fail the pipeline if violations are detected. + +## Adding Custom Checks + +To add domain-specific validation: + +1. Import the validator in `core/recovery/validator.ts` +2. Register your check with `validator.registerCheck()` +3. Implement the check function returning `InvariantViolation[]` +4. Add test coverage in `core/recovery/__tests__/validator.test.ts` + +Example: + +```typescript +validator.registerCheck({ + name: 'my-custom-check', + description: 'Validates custom business rule', + check: async () => { + const violations: InvariantViolation[] = []; + + // Your validation logic here + + return violations; + }, +}); +``` + +## Assumptions + +- Validation is read-only and never modifies data +- Checks run independently and do not depend on order +- Failed checks do not prevent other checks from running +- Network and file system access may be required for checks +- Validation runs in Node.js environment with full access to core modules + +## Maintenance + +When the domain model changes: +- Review existing checks for relevance +- Add new checks for new invariants +- Update test fixtures and expectations +- Document any new failure modes diff --git a/docs/OPERATIONAL_HEALTH.md b/docs/OPERATIONAL_HEALTH.md new file mode 100644 index 0000000..146b0ff --- /dev/null +++ b/docs/OPERATIONAL_HEALTH.md @@ -0,0 +1,221 @@ +# Operational Health Dashboard + +The operational health dashboard provides maintainers with actionable insights into system health, unresolved exceptions, and operational issues. + +## Quick Start + +```bash +npm run health:check +``` + +This command generates a comprehensive health report showing: +- Overall system status +- Health indicators for each component +- Unresolved exceptions +- Stale records requiring attention +- Key operational metrics + +## Health Indicators + +The dashboard monitors these critical components: + +### Feature Registry +Status of the feature manifest system. Unhealthy status indicates registry load failures or corruption. + +### Network Connectivity +Configuration status for Stellar network endpoints. Checks both testnet and mainnet URLs. + +### Environment Configuration +Validates presence and validity of required environment variables. + +## Status Levels + +**Healthy** +All indicators are operating normally. No immediate action required. + +**Degraded** +One or more indicators show warnings. System is functional but attention may be needed. + +**Unhealthy** +Critical issues detected. Immediate investigation and remediation required. + +## Unresolved Exceptions + +The dashboard tracks exceptions that have not been resolved: +- Exception type and message +- Timestamp and unique ID +- Stack trace when available +- Contextual information + +### Recording Exceptions + +Components can record exceptions for tracking: + +```typescript +import { healthMonitor } from '@/core/health/monitor'; + +healthMonitor.recordException({ + type: 'DataLoadError', + message: 'Failed to load critical data', + error: caughtError, + context: { userId: '123', action: 'load' }, +}); +``` + +## Stale Records + +Track records that have not been updated within expected timeframes: + +```typescript +healthMonitor.recordStaleRecord({ + id: 'record-123', + type: 'UserSession', + lastActivity: new Date('2026-01-01'), + details: { userId: '123' }, +}); +``` + +Query stale records by age: + +```typescript +const weekOldRecords = healthMonitor.getStaleRecords( + 1000 * 60 * 60 * 24 * 7 // 7 days in milliseconds +); +``` + +## Output Formats + +### Human-Readable + +Default output provides formatted dashboard for manual review: + +```bash +npm run health:check +``` + +### JSON + +Machine-readable format for integration with monitoring systems: + +```bash +npm run health:check -- --json +``` + +Example JSON output: + +```json +{ + "timestamp": "2026-09-25T00:00:00.000Z", + "overallStatus": "healthy", + "indicators": [...], + "unresolvedExceptions": [...], + "staleRecords": [...], + "metrics": { + "totalFeatures": 10, + "healthyFeatures": 10, + "unresolvedExceptionCount": 0, + "staleRecordCount": 0 + } +} +``` + +## Exit Codes + +- **0** - System is healthy or degraded +- **1** - System is unhealthy +- **2** - Health check script error + +## Monitoring Integration + +### Automated Monitoring + +Add health checks to your monitoring pipeline: + +```yaml +- name: Check operational health + run: npm run health:check -- --json > health-report.json +``` + +### Alert Configuration + +Set up alerts based on health status: + +```bash +STATUS=$(npm run health:check -- --json | jq -r '.overallStatus') +if [ "$STATUS" = "unhealthy" ]; then + # Trigger alert +fi +``` + +### Scheduled Checks + +Run health checks on a schedule: + +```bash +# Crontab entry for hourly checks +0 * * * * cd /path/to/utix && npm run health:check >> /var/log/utix-health.log 2>&1 +``` + +## Investigation Links + +When issues are detected: + +1. Review full exception details including stack traces +2. Check affected record IDs against logs +3. Cross-reference with deployment timeline +4. Verify network and infrastructure status +5. Run disaster recovery validation if needed + +## Sensitive Information + +The health dashboard automatically redacts: +- Authentication tokens +- Private keys +- User personally identifiable information +- Internal infrastructure details + +Only aggregated metrics and sanitized identifiers are included. + +## Adding Health Checks + +To add custom health indicators: + +1. Edit `core/health/monitor.ts` +2. Add your check method to `HealthMonitor` class +3. Call it from `runHealthChecks()` +4. Add test coverage in `core/health/__tests__/monitor.test.ts` + +Example: + +```typescript +private async checkCustomComponent(): Promise { + try { + // Your health check logic + + this.indicators.push({ + name: 'Custom Component', + status: 'healthy', + message: 'Component is operational', + lastChecked: new Date(), + }); + } catch (error) { + this.indicators.push({ + name: 'Custom Component', + status: 'unhealthy', + message: 'Component check failed', + details: { error: String(error) }, + lastChecked: new Date(), + }); + } +} +``` + +## Best Practices + +- Run health checks after deployments +- Monitor health check exit codes in CI/CD +- Archive health reports for trend analysis +- Investigate degraded status before it becomes unhealthy +- Document resolution steps for common issues +- Keep exception context minimal but actionable +- Set reasonable thresholds for stale record alerts diff --git a/docs/RBAC.md b/docs/RBAC.md new file mode 100644 index 0000000..321ef0d --- /dev/null +++ b/docs/RBAC.md @@ -0,0 +1,97 @@ +# Role-Based Access Control + +Utix implements a simple role-based access control system to protect privileged operations and administrative endpoints. + +## Roles + +### Viewer +Default role for all users. Can access all public tools and features. + +**Permissions:** +- `view:tools` - Access utility tools +- `view:dashboard` - View dashboard + +### Maintainer +Administrative role for project maintainers and operators. + +**Permissions:** +- All viewer permissions +- `admin:health` - Access operational health dashboard +- `admin:recovery` - Run disaster recovery validation +- `admin:metrics` - View system metrics + +### Service +Limited administrative role for automated services and monitoring systems. + +**Permissions:** +- `admin:health` - Access operational health dashboard +- `admin:metrics` - View system metrics + +## Server-Side Enforcement + +All permission checks are enforced server-side and cannot be bypassed through UI manipulation. + +```typescript +import { requirePermission, getAuthContext } from '@/core/auth/middleware'; + +export async function adminAction() { + const context = getAuthContext(); + requirePermission(context, 'admin:health'); + + // Privileged operation here +} +``` + +## Using the Permission Wrapper + +For server actions and API routes, use the `withPermission` wrapper: + +```typescript +import { withPermission } from '@/core/auth/middleware'; + +export const protectedAction = withPermission( + 'admin:recovery', + async (param: string) => { + // This code only runs if permission is granted + return performRecoveryValidation(param); + } +); +``` + +## Configuration + +Set the user role via environment variable: + +```bash +UTIX_USER_ROLE=maintainer +``` + +If not set, defaults to `viewer`. + +## Client-Side UI Hints + +While security is enforced server-side, the UI can hide or disable unauthorized actions for better UX: + +```typescript +import { hasPermission } from '@/core/auth/types'; + +const canAccessAdmin = hasPermission(userRole, 'admin:health'); + +{canAccessAdmin && } +``` + +**Important:** UI checks are hints only. Never rely on them for security. + +## Testing + +All permission combinations are tested in `core/auth/__tests__/permissions.test.ts`. Every privileged action must have corresponding permission tests. + +## Future Extensions + +When adding new privileged operations: + +1. Define the permission in `core/auth/types.ts` +2. Add it to the appropriate role in `ROLE_PERMISSIONS` +3. Protect the handler with `requirePermission` or `withPermission` +4. Add test coverage for the permission check +5. Update this documentation diff --git a/package.json b/package.json index f09cf55..3b1ab74 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "next dev", "prebuild": "npm run registry", "build": "next build", + "postbuild": "node scripts/bundle-budget.mjs", "start": "next start", "prelint": "npm run registry", "lint": "eslint .", @@ -22,7 +23,10 @@ "postinstall": "node scripts/generate-registry.mjs", "check": "npm run lint && npm run test && npm run verify:features && npm run verify:issues && npm run build", "issues": "node scripts/create-issues.mjs", - "issues:drips": "node scripts/create-issues.mjs --target drips" + "issues:drips": "node scripts/create-issues.mjs --target drips", + "recovery:validate": "node scripts/validate-recovery.mjs", + "health:check": "node scripts/health-check.mjs", + "bundle:check": "node scripts/bundle-budget.mjs" }, "dependencies": { "@stellar/stellar-sdk": "^13.3.0", diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs new file mode 100644 index 0000000..0cb9f5d --- /dev/null +++ b/scripts/bundle-budget.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node + +/** + * Per-feature bundle size budget enforcement + * + * Analyzes Next.js build output to compute per-feature-slice bundle sizes + * and enforces documented budgets. + * + * This prevents any single feature from quietly pulling in heavy dependencies + * that bloat its lazy-loaded chunk. + */ + +import { readFileSync, existsSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, '..'); + +const BASELINE_PATH = join(ROOT, 'bundle-baseline.json'); +const BUILD_MANIFEST_PATH = join(ROOT, '.next/build-manifest.json'); + +const DEFAULT_BUDGET_KB = 150; + +const FEATURE_BUDGETS = { + 'address-validator': 120, + 'balance-viewer': 150, + 'trustline-checker': 100, + 'payment-qr': 180, + 'transaction-lookup': 140, + 'freighter-connect': 80, + 'testnet-faucet': 90, +}; + +function loadBaseline() { + if (!existsSync(BASELINE_PATH)) { + return {}; + } + + try { + return JSON.parse(readFileSync(BASELINE_PATH, 'utf-8')); + } catch (error) { + console.warn(`Warning: Could not load baseline from ${BASELINE_PATH}`); + return {}; + } +} + +function loadBuildManifest() { + if (!existsSync(BUILD_MANIFEST_PATH)) { + throw new Error(`Build manifest not found at ${BUILD_MANIFEST_PATH}. Run 'npm run build' first.`); + } + + return JSON.parse(readFileSync(BUILD_MANIFEST_PATH, 'utf-8')); +} + +function computeChunkSize(chunkPath) { + const fullPath = join(ROOT, '.next', chunkPath); + + if (!existsSync(fullPath)) { + return 0; + } + + const stats = readFileSync(fullPath); + return stats.length; +} + +function analyzeFeatureSlices(manifest) { + const featureSizes = {}; + + for (const [route, chunks] of Object.entries(manifest.pages || {})) { + const match = route.match(/^\/tools\/\[slug\]$/); + if (!match) continue; + + let totalSize = 0; + const chunkDetails = []; + + for (const chunk of chunks) { + if (chunk.includes('/_app') || chunk.includes('/_error')) { + continue; + } + + if (chunk.includes('/webpack') || chunk.includes('/node_modules/')) { + continue; + } + + const size = computeChunkSize(chunk); + if (size > 0) { + totalSize += size; + chunkDetails.push({ path: chunk, size }); + } + } + + featureSizes[route] = { + totalSize, + chunks: chunkDetails, + }; + } + + const dynamicChunks = manifest.sortedPages?.filter(page => + page.startsWith('dynamic/') && page.includes('features/') + ) || []; + + for (const chunk of dynamicChunks) { + const match = chunk.match(/features\/([^\/]+)/); + if (!match) continue; + + const featureName = match[1]; + const size = computeChunkSize(`static/chunks/${chunk}.js`); + + if (size > 0) { + if (!featureSizes[featureName]) { + featureSizes[featureName] = { + totalSize: 0, + chunks: [], + }; + } + + featureSizes[featureName].totalSize += size; + featureSizes[featureName].chunks.push({ + path: `static/chunks/${chunk}.js`, + size, + }); + } + } + + return featureSizes; +} + +function checkBudgets(featureSizes, baseline) { + const results = []; + let hasViolations = false; + + for (const [feature, data] of Object.entries(featureSizes)) { + const sizeKB = Math.round(data.totalSize / 1024); + const budgetKB = FEATURE_BUDGETS[feature] || DEFAULT_BUDGET_KB; + const baselineSizeKB = baseline[feature] ? Math.round(baseline[feature].totalSize / 1024) : 0; + const delta = sizeKB - baselineSizeKB; + + const status = sizeKB > budgetKB ? 'EXCEEDED' : 'OK'; + const withinBudget = sizeKB <= budgetKB; + + if (!withinBudget) { + hasViolations = true; + } + + results.push({ + feature, + sizeKB, + budgetKB, + baselineSizeKB, + delta, + status, + withinBudget, + chunks: data.chunks, + }); + } + + return { results, hasViolations }; +} + +function formatReport(results, hasViolations) { + const lines = []; + + lines.push('='.repeat(80)); + lines.push('BUNDLE SIZE BUDGET REPORT'); + lines.push('='.repeat(80)); + lines.push(''); + + if (results.length === 0) { + lines.push('No feature slices detected in build output.'); + lines.push('This is normal for a fresh build without features.'); + } else { + lines.push('Feature'.padEnd(30) + 'Size'.padEnd(15) + 'Budget'.padEnd(15) + 'Status'); + lines.push('-'.repeat(80)); + + for (const result of results) { + const featureName = result.feature.padEnd(30); + const size = `${result.sizeKB} KB`.padEnd(15); + const budget = `${result.budgetKB} KB`.padEnd(15); + const status = result.status; + + lines.push(`${featureName}${size}${budget}${status}`); + + if (result.delta !== 0 && result.baselineSizeKB > 0) { + const deltaStr = result.delta > 0 ? `+${result.delta}` : `${result.delta}`; + lines.push(` (${deltaStr} KB from baseline)`); + } + } + } + + lines.push(''); + lines.push('='.repeat(80)); + + if (hasViolations) { + lines.push(''); + lines.push('BUDGET VIOLATIONS DETECTED'); + lines.push(''); + lines.push('To resolve:'); + lines.push(' 1. Review dependencies added to the feature'); + lines.push(' 2. Consider code-splitting or lazy loading'); + lines.push(' 3. Request budget increase if justified (update FEATURE_BUDGETS)'); + lines.push(''); + } + + return lines.join('\n'); +} + +function saveBaseline(featureSizes) { + const baseline = {}; + + for (const [feature, data] of Object.entries(featureSizes)) { + baseline[feature] = { + totalSize: data.totalSize, + sizeKB: Math.round(data.totalSize / 1024), + timestamp: new Date().toISOString(), + }; + } + + writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2)); + console.log(`\nBaseline saved to ${BASELINE_PATH}`); +} + +async function main() { + const args = process.argv.slice(2); + const updateBaseline = args.includes('--update-baseline'); + const jsonOutput = args.includes('--json'); + + try { + const baseline = loadBaseline(); + const manifest = loadBuildManifest(); + const featureSizes = analyzeFeatureSlices(manifest); + const { results, hasViolations } = checkBudgets(featureSizes, baseline); + + if (jsonOutput) { + console.log(JSON.stringify({ results, hasViolations }, null, 2)); + } else { + console.log(formatReport(results, hasViolations)); + } + + if (updateBaseline) { + saveBaseline(featureSizes); + } + + if (hasViolations && !updateBaseline) { + process.exit(1); + } + } catch (error) { + console.error('Bundle budget check failed:'); + console.error(error.message); + process.exit(2); + } +} + +main(); diff --git a/scripts/health-check.mjs b/scripts/health-check.mjs new file mode 100644 index 0000000..21be04a --- /dev/null +++ b/scripts/health-check.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +/** + * Operational health check command + * + * Generates a dashboard showing system health, unresolved exceptions, + * and stale records for maintainer review. + * + * Usage: + * npm run health:check + * node scripts/health-check.mjs + * node scripts/health-check.mjs --json + */ + +import { healthMonitor, formatHealthReport } from '../core/health/monitor.ts'; + +async function main() { + const isJson = process.argv.includes('--json'); + + try { + const summary = await healthMonitor.checkHealth(); + + if (isJson) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(formatHealthReport(summary)); + } + + process.exit(summary.overallStatus === 'unhealthy' ? 1 : 0); + } catch (error) { + console.error('Health check failed with error:'); + console.error(error); + process.exit(2); + } +} + +main(); diff --git a/scripts/validate-recovery.mjs b/scripts/validate-recovery.mjs new file mode 100644 index 0000000..1d8c783 --- /dev/null +++ b/scripts/validate-recovery.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +/** + * Disaster recovery validation command + * + * Read-only validation of core domain invariants. + * Run after restore or migration to verify data integrity. + * + * Usage: + * npm run recovery:validate + * node scripts/validate-recovery.mjs + * node scripts/validate-recovery.mjs --json + */ + +import { validator, formatValidationReport } from '../core/recovery/validator.ts'; + +async function main() { + const isJson = process.argv.includes('--json'); + + try { + console.log('Running disaster recovery validation...\n'); + + const result = await validator.validate(); + + if (isJson) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatValidationReport(result)); + } + + process.exit(result.passed ? 0 : 1); + } catch (error) { + console.error('Validation failed with error:'); + console.error(error); + process.exit(2); + } +} + +main();