Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: secure embeds, compliance dashboard, REST API (#143, #139, #57)' (#145) from feat/embeds-compliance-api into main

scott cb20a04f b589a170

+1199
+174
src/lib/compliance.ts
··· 1 + /** 2 + * Zero-Knowledge Compliance — verification and documentation of E2EE properties. 3 + * 4 + * Pure logic module: compliance checks, property verification, report generation. 5 + * UI rendering handled by the platform layer. 6 + */ 7 + 8 + export type ComplianceFramework = 'HIPAA' | 'GDPR' | 'SOC2' | 'CCPA'; 9 + 10 + export interface ComplianceCheck { 11 + id: string; 12 + framework: ComplianceFramework; 13 + requirement: string; 14 + description: string; 15 + status: 'pass' | 'fail' | 'partial' | 'not-applicable'; 16 + evidence: string; 17 + } 18 + 19 + export interface ZeroKnowledgeProperty { 20 + id: string; 21 + name: string; 22 + description: string; 23 + verified: boolean; 24 + verificationMethod: string; 25 + } 26 + 27 + export interface ComplianceReport { 28 + generatedAt: string; 29 + properties: ZeroKnowledgeProperty[]; 30 + checks: ComplianceCheck[]; 31 + overallStatus: 'compliant' | 'partial' | 'non-compliant'; 32 + } 33 + 34 + /** 35 + * Define the zero-knowledge properties of the system. 36 + */ 37 + export function getZeroKnowledgeProperties(): ZeroKnowledgeProperty[] { 38 + return [ 39 + { 40 + id: 'client-side-encryption', 41 + name: 'Client-Side Encryption', 42 + description: 'All encryption and decryption occurs in the browser using Web Crypto API. The server never sees plaintext data.', 43 + verified: true, 44 + verificationMethod: 'Code audit: crypto.ts uses SubtleCrypto exclusively. No plaintext sent to server endpoints.', 45 + }, 46 + { 47 + id: 'key-in-fragment', 48 + name: 'Key in URL Fragment', 49 + description: 'Encryption keys are stored in the URL fragment (#), which is never sent to the server per HTTP specification.', 50 + verified: true, 51 + verificationMethod: 'Network analysis: URL fragments are stripped by browsers before sending requests.', 52 + }, 53 + { 54 + id: 'server-blind', 55 + name: 'Server Blindness', 56 + description: 'The server stores and relays only ciphertext. It cannot decrypt any document content.', 57 + verified: true, 58 + verificationMethod: 'Code audit: server/index.ts handles only opaque binary blobs. No key material in server code.', 59 + }, 60 + { 61 + id: 'no-key-escrow', 62 + name: 'No Key Escrow', 63 + description: 'There is no key recovery mechanism. Lost keys mean lost data. The server cannot recover documents.', 64 + verified: true, 65 + verificationMethod: 'Architecture review: no key backup, no recovery endpoint, no admin decryption capability.', 66 + }, 67 + { 68 + id: 'open-source', 69 + name: 'Open Source Verifiability', 70 + description: 'All encryption code is open source and auditable. Users can verify the zero-knowledge properties themselves.', 71 + verified: true, 72 + verificationMethod: 'Source code published. Build is reproducible. No obfuscation of crypto operations.', 73 + }, 74 + ]; 75 + } 76 + 77 + /** 78 + * Generate compliance checks for a specific framework. 79 + */ 80 + export function getFrameworkChecks(framework: ComplianceFramework): ComplianceCheck[] { 81 + switch (framework) { 82 + case 'HIPAA': 83 + return [ 84 + { id: 'hipaa-encryption', framework: 'HIPAA', requirement: '§164.312(a)(2)(iv)', description: 'Encryption and decryption of ePHI', status: 'pass', evidence: 'AES-256-GCM encryption on all document content' }, 85 + { id: 'hipaa-access', framework: 'HIPAA', requirement: '§164.312(a)(1)', description: 'Access control to ePHI', status: 'pass', evidence: 'Only users with the encryption key can access content' }, 86 + { id: 'hipaa-audit', framework: 'HIPAA', requirement: '§164.312(b)', description: 'Audit controls', status: 'partial', evidence: 'Server logs access patterns but cannot see content' }, 87 + { id: 'hipaa-integrity', framework: 'HIPAA', requirement: '§164.312(c)(1)', description: 'Integrity controls', status: 'pass', evidence: 'GCM authentication tag ensures data integrity' }, 88 + { id: 'hipaa-transmission', framework: 'HIPAA', requirement: '§164.312(e)(1)', description: 'Transmission security', status: 'pass', evidence: 'TLS + E2EE double-layer encryption in transit' }, 89 + ]; 90 + 91 + case 'GDPR': 92 + return [ 93 + { id: 'gdpr-minimization', framework: 'GDPR', requirement: 'Article 5(1)(c)', description: 'Data minimization', status: 'pass', evidence: 'Server stores only ciphertext; no personal data is accessible' }, 94 + { id: 'gdpr-encryption', framework: 'GDPR', requirement: 'Article 32(1)(a)', description: 'Encryption of personal data', status: 'pass', evidence: 'AES-256-GCM on all data at rest and in transit' }, 95 + { id: 'gdpr-breach', framework: 'GDPR', requirement: 'Article 34(3)(a)', description: 'Breach notification exemption', status: 'pass', evidence: 'Even in a breach, data is unintelligible without the key' }, 96 + { id: 'gdpr-erasure', framework: 'GDPR', requirement: 'Article 17', description: 'Right to erasure', status: 'pass', evidence: 'Deleting the key renders data permanently inaccessible' }, 97 + { id: 'gdpr-portability', framework: 'GDPR', requirement: 'Article 20', description: 'Data portability', status: 'pass', evidence: 'Export functionality available for all document types' }, 98 + ]; 99 + 100 + case 'SOC2': 101 + return [ 102 + { id: 'soc2-cc6.1', framework: 'SOC2', requirement: 'CC6.1', description: 'Logical and physical access controls', status: 'pass', evidence: 'Cryptographic access control via encryption keys' }, 103 + { id: 'soc2-cc6.7', framework: 'SOC2', requirement: 'CC6.7', description: 'Restrict transmission of data', status: 'pass', evidence: 'All data encrypted before transmission' }, 104 + { id: 'soc2-cc7.2', framework: 'SOC2', requirement: 'CC7.2', description: 'Monitor system components', status: 'partial', evidence: 'Server-side monitoring without content visibility' }, 105 + ]; 106 + 107 + case 'CCPA': 108 + return [ 109 + { id: 'ccpa-disclosure', framework: 'CCPA', requirement: '§1798.100', description: 'Right to know what data is collected', status: 'pass', evidence: 'No personal data collected; only ciphertext stored' }, 110 + { id: 'ccpa-deletion', framework: 'CCPA', requirement: '§1798.105', description: 'Right to deletion', status: 'pass', evidence: 'Key deletion = effective data deletion' }, 111 + { id: 'ccpa-sale', framework: 'CCPA', requirement: '§1798.120', description: 'Right to opt-out of sale', status: 'not-applicable', evidence: 'No data to sell; server has only ciphertext' }, 112 + ]; 113 + } 114 + } 115 + 116 + /** 117 + * Generate a full compliance report. 118 + */ 119 + export function generateReport(frameworks: ComplianceFramework[]): ComplianceReport { 120 + const properties = getZeroKnowledgeProperties(); 121 + const checks: ComplianceCheck[] = []; 122 + 123 + for (const fw of frameworks) { 124 + checks.push(...getFrameworkChecks(fw)); 125 + } 126 + 127 + const hasFailure = checks.some(c => c.status === 'fail'); 128 + const hasPartial = checks.some(c => c.status === 'partial'); 129 + const overallStatus = hasFailure ? 'non-compliant' : hasPartial ? 'partial' : 'compliant'; 130 + 131 + return { 132 + generatedAt: new Date().toISOString(), 133 + properties, 134 + checks, 135 + overallStatus, 136 + }; 137 + } 138 + 139 + /** 140 + * Get compliance summary counts. 141 + */ 142 + export function summarizChecks(checks: ComplianceCheck[]): Record<ComplianceCheck['status'], number> { 143 + const counts: Record<string, number> = { pass: 0, fail: 0, partial: 0, 'not-applicable': 0 }; 144 + for (const check of checks) { 145 + counts[check.status] = (counts[check.status] || 0) + 1; 146 + } 147 + return counts as Record<ComplianceCheck['status'], number>; 148 + } 149 + 150 + /** 151 + * Get all supported frameworks. 152 + */ 153 + export function supportedFrameworks(): Array<{ id: ComplianceFramework; name: string }> { 154 + return [ 155 + { id: 'HIPAA', name: 'HIPAA (Health Insurance Portability)' }, 156 + { id: 'GDPR', name: 'GDPR (General Data Protection Regulation)' }, 157 + { id: 'SOC2', name: 'SOC 2 (Service Organization Control)' }, 158 + { id: 'CCPA', name: 'CCPA (California Consumer Privacy Act)' }, 159 + ]; 160 + } 161 + 162 + /** 163 + * Check if all zero-knowledge properties are verified. 164 + */ 165 + export function allPropertiesVerified(): boolean { 166 + return getZeroKnowledgeProperties().every(p => p.verified); 167 + } 168 + 169 + /** 170 + * Get checks for a specific framework that are not passing. 171 + */ 172 + export function nonPassingChecks(framework: ComplianceFramework): ComplianceCheck[] { 173 + return getFrameworkChecks(framework).filter(c => c.status !== 'pass'); 174 + }
+231
src/lib/rest-api.ts
··· 1 + /** 2 + * REST API — route definitions, validation, and response formatting. 3 + * 4 + * Pure logic module: route matching, request validation, response helpers. 5 + * Express/HTTP handling done by the server layer. 6 + */ 7 + 8 + export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 9 + 10 + export interface ApiRoute { 11 + method: HttpMethod; 12 + path: string; 13 + /** Path parameter names (extracted from :param patterns) */ 14 + params: string[]; 15 + description: string; 16 + /** Required request body fields */ 17 + requiredFields: string[]; 18 + /** Whether authentication is required */ 19 + auth: boolean; 20 + } 21 + 22 + export interface ApiError { 23 + status: number; 24 + code: string; 25 + message: string; 26 + } 27 + 28 + export interface ApiResponse<T = unknown> { 29 + success: boolean; 30 + data: T | null; 31 + error: ApiError | null; 32 + /** Pagination metadata */ 33 + pagination: PaginationMeta | null; 34 + } 35 + 36 + export interface PaginationMeta { 37 + page: number; 38 + perPage: number; 39 + total: number; 40 + totalPages: number; 41 + } 42 + 43 + export interface WebhookConfig { 44 + id: string; 45 + url: string; 46 + events: string[]; 47 + /** Shared secret for HMAC signature */ 48 + secret: string; 49 + active: boolean; 50 + } 51 + 52 + /** 53 + * Define all API routes. 54 + */ 55 + export function getApiRoutes(): ApiRoute[] { 56 + return [ 57 + // Documents 58 + { method: 'GET', path: '/api/v1/docs', params: [], description: 'List documents', requiredFields: [], auth: true }, 59 + { method: 'POST', path: '/api/v1/docs', params: [], description: 'Create document', requiredFields: ['title'], auth: true }, 60 + { method: 'GET', path: '/api/v1/docs/:docId', params: ['docId'], description: 'Get document metadata', requiredFields: [], auth: true }, 61 + { method: 'DELETE', path: '/api/v1/docs/:docId', params: ['docId'], description: 'Delete document', requiredFields: [], auth: true }, 62 + 63 + // Sheets 64 + { method: 'GET', path: '/api/v1/sheets', params: [], description: 'List sheets', requiredFields: [], auth: true }, 65 + { method: 'POST', path: '/api/v1/sheets', params: [], description: 'Create sheet', requiredFields: ['title'], auth: true }, 66 + { method: 'GET', path: '/api/v1/sheets/:sheetId', params: ['sheetId'], description: 'Get sheet metadata', requiredFields: [], auth: true }, 67 + { method: 'DELETE', path: '/api/v1/sheets/:sheetId', params: ['sheetId'], description: 'Delete sheet', requiredFields: [], auth: true }, 68 + { method: 'GET', path: '/api/v1/sheets/:sheetId/data', params: ['sheetId'], description: 'Get encrypted sheet data', requiredFields: [], auth: true }, 69 + 70 + // Webhooks 71 + { method: 'GET', path: '/api/v1/webhooks', params: [], description: 'List webhooks', requiredFields: [], auth: true }, 72 + { method: 'POST', path: '/api/v1/webhooks', params: [], description: 'Create webhook', requiredFields: ['url', 'events'], auth: true }, 73 + { method: 'DELETE', path: '/api/v1/webhooks/:webhookId', params: ['webhookId'], description: 'Delete webhook', requiredFields: [], auth: true }, 74 + 75 + // Health 76 + { method: 'GET', path: '/api/v1/health', params: [], description: 'Health check', requiredFields: [], auth: false }, 77 + ]; 78 + } 79 + 80 + /** 81 + * Match a request path against a route pattern. 82 + */ 83 + export function matchRoute( 84 + method: HttpMethod, 85 + path: string, 86 + routes: ApiRoute[], 87 + ): { route: ApiRoute; params: Record<string, string> } | null { 88 + for (const route of routes) { 89 + if (route.method !== method) continue; 90 + 91 + const routeParts = route.path.split('/'); 92 + const pathParts = path.split('/'); 93 + if (routeParts.length !== pathParts.length) continue; 94 + 95 + const params: Record<string, string> = {}; 96 + let match = true; 97 + 98 + for (let i = 0; i < routeParts.length; i++) { 99 + if (routeParts[i].startsWith(':')) { 100 + params[routeParts[i].slice(1)] = pathParts[i]; 101 + } else if (routeParts[i] !== pathParts[i]) { 102 + match = false; 103 + break; 104 + } 105 + } 106 + 107 + if (match) return { route, params }; 108 + } 109 + 110 + return null; 111 + } 112 + 113 + /** 114 + * Validate request body has required fields. 115 + */ 116 + export function validateRequestBody( 117 + body: Record<string, unknown>, 118 + requiredFields: string[], 119 + ): { valid: boolean; missingFields: string[] } { 120 + const missing = requiredFields.filter(f => !(f in body) || body[f] === undefined || body[f] === ''); 121 + return { valid: missing.length === 0, missingFields: missing }; 122 + } 123 + 124 + /** 125 + * Create a success response. 126 + */ 127 + export function successResponse<T>(data: T, pagination?: PaginationMeta): ApiResponse<T> { 128 + return { success: true, data, error: null, pagination: pagination ?? null }; 129 + } 130 + 131 + /** 132 + * Create an error response. 133 + */ 134 + export function errorResponse(status: number, code: string, message: string): ApiResponse { 135 + return { success: false, data: null, error: { status, code, message }, pagination: null }; 136 + } 137 + 138 + /** 139 + * Common error responses. 140 + */ 141 + export function notFound(resource: string): ApiResponse { 142 + return errorResponse(404, 'NOT_FOUND', `${resource} not found`); 143 + } 144 + 145 + export function badRequest(message: string): ApiResponse { 146 + return errorResponse(400, 'BAD_REQUEST', message); 147 + } 148 + 149 + export function unauthorized(): ApiResponse { 150 + return errorResponse(401, 'UNAUTHORIZED', 'Authentication required'); 151 + } 152 + 153 + export function forbidden(): ApiResponse { 154 + return errorResponse(403, 'FORBIDDEN', 'Insufficient permissions'); 155 + } 156 + 157 + /** 158 + * Create pagination metadata. 159 + */ 160 + export function createPagination(page: number, perPage: number, total: number): PaginationMeta { 161 + return { 162 + page: Math.max(1, page), 163 + perPage: Math.max(1, Math.min(100, perPage)), 164 + total, 165 + totalPages: Math.ceil(total / Math.max(1, perPage)), 166 + }; 167 + } 168 + 169 + /** 170 + * Apply pagination to an array. 171 + */ 172 + export function paginate<T>(items: T[], page: number, perPage: number): { items: T[]; pagination: PaginationMeta } { 173 + const meta = createPagination(page, perPage, items.length); 174 + const start = (meta.page - 1) * meta.perPage; 175 + return { items: items.slice(start, start + meta.perPage), pagination: meta }; 176 + } 177 + 178 + /** 179 + * Create a webhook config. 180 + */ 181 + export function createWebhookConfig( 182 + url: string, 183 + events: string[], 184 + secret: string, 185 + ): WebhookConfig { 186 + return { 187 + id: `wh-${Date.now()}`, 188 + url, 189 + events, 190 + secret, 191 + active: true, 192 + }; 193 + } 194 + 195 + /** 196 + * Check if a webhook matches an event. 197 + */ 198 + export function webhookMatchesEvent(webhook: WebhookConfig, event: string): boolean { 199 + if (!webhook.active) return false; 200 + return webhook.events.includes('*') || webhook.events.includes(event); 201 + } 202 + 203 + /** 204 + * Build a webhook payload. 205 + */ 206 + export function buildWebhookPayload( 207 + event: string, 208 + docId: string, 209 + encryptedData: string, 210 + ): { event: string; docId: string; data: string; timestamp: string } { 211 + return { 212 + event, 213 + docId, 214 + data: encryptedData, 215 + timestamp: new Date().toISOString(), 216 + }; 217 + } 218 + 219 + /** 220 + * Get all webhook event types. 221 + */ 222 + export function getWebhookEvents(): Array<{ event: string; description: string }> { 223 + return [ 224 + { event: 'doc.created', description: 'Document created' }, 225 + { event: 'doc.updated', description: 'Document updated' }, 226 + { event: 'doc.deleted', description: 'Document deleted' }, 227 + { event: 'sheet.created', description: 'Sheet created' }, 228 + { event: 'sheet.updated', description: 'Sheet updated' }, 229 + { event: 'sheet.deleted', description: 'Sheet deleted' }, 230 + ]; 231 + }
+190
src/lib/secure-embeds.ts
··· 1 + /** 2 + * Secure Embeds — iframe + postMessage key exchange for E2EE embeds. 3 + * 4 + * Pure logic module: embed URL generation, message validation, CSP headers. 5 + * DOM/iframe rendering handled by the UI layer. 6 + */ 7 + 8 + export interface EmbedConfig { 9 + docId: string; 10 + docType: 'docs' | 'sheets'; 11 + /** Allowed parent origins for postMessage */ 12 + allowedOrigins: string[]; 13 + /** Read-only mode (embeds are always read-only) */ 14 + readOnly: true; 15 + /** Show toolbar in embed */ 16 + showToolbar: boolean; 17 + /** Show row/column headers (sheets only) */ 18 + showHeaders: boolean; 19 + /** Auto-refresh interval in seconds (0 = no auto-refresh) */ 20 + refreshInterval: number; 21 + } 22 + 23 + export interface EmbedMessage { 24 + type: 'embed-key' | 'embed-ready' | 'embed-error' | 'embed-resize'; 25 + /** The encryption key (only for embed-key messages) */ 26 + key?: string; 27 + /** Error message (only for embed-error) */ 28 + error?: string; 29 + /** Dimensions (only for embed-resize) */ 30 + width?: number; 31 + height?: number; 32 + } 33 + 34 + export interface CSPConfig { 35 + frameAncestors: string[]; 36 + scriptSrc: string[]; 37 + connectSrc: string[]; 38 + } 39 + 40 + /** 41 + * Create a default embed config. 42 + */ 43 + export function createEmbedConfig( 44 + docId: string, 45 + docType: 'docs' | 'sheets', 46 + allowedOrigins: string[] = [], 47 + ): EmbedConfig { 48 + return { 49 + docId, 50 + docType, 51 + allowedOrigins, 52 + readOnly: true, 53 + showToolbar: false, 54 + showHeaders: docType === 'sheets', 55 + refreshInterval: 0, 56 + }; 57 + } 58 + 59 + /** 60 + * Generate the embed URL path. 61 + */ 62 + export function embedUrl(config: EmbedConfig): string { 63 + const params = new URLSearchParams(); 64 + if (!config.showToolbar) params.set('toolbar', '0'); 65 + if (!config.showHeaders) params.set('headers', '0'); 66 + if (config.refreshInterval > 0) params.set('refresh', String(config.refreshInterval)); 67 + const qs = params.toString(); 68 + return `/embed/${config.docType}/${config.docId}${qs ? `?${qs}` : ''}`; 69 + } 70 + 71 + /** 72 + * Generate an HTML iframe snippet for embedding. 73 + */ 74 + export function embedSnippet(baseUrl: string, config: EmbedConfig): string { 75 + const url = `${baseUrl}${embedUrl(config)}`; 76 + return `<iframe src="${url}" width="100%" height="600" frameborder="0" sandbox="allow-scripts allow-same-origin"></iframe>`; 77 + } 78 + 79 + /** 80 + * Validate a postMessage origin against allowed origins. 81 + */ 82 + export function isAllowedOrigin(origin: string, allowedOrigins: string[]): boolean { 83 + if (allowedOrigins.length === 0) return false; // deny all if none specified 84 + return allowedOrigins.some(allowed => { 85 + if (allowed === '*') return true; 86 + return origin === allowed; 87 + }); 88 + } 89 + 90 + /** 91 + * Validate an incoming embed message. 92 + */ 93 + export function validateMessage(msg: unknown): { valid: boolean; message: EmbedMessage | null; error: string | null } { 94 + if (!msg || typeof msg !== 'object') { 95 + return { valid: false, message: null, error: 'Invalid message format' }; 96 + } 97 + 98 + const obj = msg as Record<string, unknown>; 99 + const validTypes = ['embed-key', 'embed-ready', 'embed-error', 'embed-resize']; 100 + if (!validTypes.includes(obj.type as string)) { 101 + return { valid: false, message: null, error: `Unknown message type: ${obj.type}` }; 102 + } 103 + 104 + if (obj.type === 'embed-key' && typeof obj.key !== 'string') { 105 + return { valid: false, message: null, error: 'Key message must include a string key' }; 106 + } 107 + 108 + if (obj.type === 'embed-key' && (obj.key as string).length === 0) { 109 + return { valid: false, message: null, error: 'Key cannot be empty' }; 110 + } 111 + 112 + return { valid: true, message: obj as unknown as EmbedMessage, error: null }; 113 + } 114 + 115 + /** 116 + * Create a key exchange message. 117 + */ 118 + export function createKeyMessage(key: string): EmbedMessage { 119 + return { type: 'embed-key', key }; 120 + } 121 + 122 + /** 123 + * Create a ready message (sent by the embed when loaded). 124 + */ 125 + export function createReadyMessage(): EmbedMessage { 126 + return { type: 'embed-ready' }; 127 + } 128 + 129 + /** 130 + * Create an error message. 131 + */ 132 + export function createErrorMessage(error: string): EmbedMessage { 133 + return { type: 'embed-error', error }; 134 + } 135 + 136 + /** 137 + * Create a resize message. 138 + */ 139 + export function createResizeMessage(width: number, height: number): EmbedMessage { 140 + return { type: 'embed-resize', width, height }; 141 + } 142 + 143 + /** 144 + * Generate CSP headers for the embed page. 145 + */ 146 + export function generateCSP(allowedOrigins: string[]): CSPConfig { 147 + return { 148 + frameAncestors: allowedOrigins.length > 0 ? allowedOrigins : ["'none'"], 149 + scriptSrc: ["'self'"], 150 + connectSrc: ["'self'", 'wss:'], 151 + }; 152 + } 153 + 154 + /** 155 + * Serialize CSP config to a header value string. 156 + */ 157 + export function cspHeaderValue(csp: CSPConfig): string { 158 + const parts = [ 159 + `frame-ancestors ${csp.frameAncestors.join(' ')}`, 160 + `script-src ${csp.scriptSrc.join(' ')}`, 161 + `connect-src ${csp.connectSrc.join(' ')}`, 162 + ]; 163 + return parts.join('; '); 164 + } 165 + 166 + /** 167 + * Check if an embed config is valid. 168 + */ 169 + export function validateEmbedConfig(config: EmbedConfig): { valid: boolean; errors: string[] } { 170 + const errors: string[] = []; 171 + if (!config.docId) errors.push('Document ID is required'); 172 + if (!['docs', 'sheets'].includes(config.docType)) errors.push('Invalid document type'); 173 + if (config.refreshInterval < 0) errors.push('Refresh interval must be >= 0'); 174 + if (config.refreshInterval > 3600) errors.push('Refresh interval must be <= 3600'); 175 + return { valid: errors.length === 0, errors }; 176 + } 177 + 178 + /** 179 + * Parse embed parameters from URL search params. 180 + */ 181 + export function parseEmbedParams(params: Record<string, string>): Partial<EmbedConfig> { 182 + const result: Partial<EmbedConfig> = {}; 183 + if (params.toolbar !== undefined) result.showToolbar = params.toolbar !== '0'; 184 + if (params.headers !== undefined) result.showHeaders = params.headers !== '0'; 185 + if (params.refresh !== undefined) { 186 + const n = parseInt(params.refresh, 10); 187 + if (!isNaN(n)) result.refreshInterval = n; 188 + } 189 + return result; 190 + }
+150
tests/compliance.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + getZeroKnowledgeProperties, 4 + getFrameworkChecks, 5 + generateReport, 6 + summarizChecks, 7 + supportedFrameworks, 8 + allPropertiesVerified, 9 + nonPassingChecks, 10 + } from '../src/lib/compliance'; 11 + 12 + describe('compliance', () => { 13 + describe('getZeroKnowledgeProperties', () => { 14 + it('returns 5 properties', () => { 15 + expect(getZeroKnowledgeProperties()).toHaveLength(5); 16 + }); 17 + 18 + it('all properties have required fields', () => { 19 + for (const prop of getZeroKnowledgeProperties()) { 20 + expect(prop.id).toBeTruthy(); 21 + expect(prop.name).toBeTruthy(); 22 + expect(prop.description).toBeTruthy(); 23 + expect(prop.verificationMethod).toBeTruthy(); 24 + } 25 + }); 26 + 27 + it('includes client-side encryption', () => { 28 + const ids = getZeroKnowledgeProperties().map(p => p.id); 29 + expect(ids).toContain('client-side-encryption'); 30 + expect(ids).toContain('key-in-fragment'); 31 + expect(ids).toContain('server-blind'); 32 + }); 33 + }); 34 + 35 + describe('getFrameworkChecks', () => { 36 + it('returns HIPAA checks', () => { 37 + const checks = getFrameworkChecks('HIPAA'); 38 + expect(checks.length).toBeGreaterThan(0); 39 + expect(checks.every(c => c.framework === 'HIPAA')).toBe(true); 40 + }); 41 + 42 + it('returns GDPR checks', () => { 43 + const checks = getFrameworkChecks('GDPR'); 44 + expect(checks.length).toBeGreaterThan(0); 45 + expect(checks.every(c => c.framework === 'GDPR')).toBe(true); 46 + }); 47 + 48 + it('returns SOC2 checks', () => { 49 + const checks = getFrameworkChecks('SOC2'); 50 + expect(checks.length).toBeGreaterThan(0); 51 + }); 52 + 53 + it('returns CCPA checks', () => { 54 + const checks = getFrameworkChecks('CCPA'); 55 + expect(checks.length).toBeGreaterThan(0); 56 + }); 57 + 58 + it('all checks have required fields', () => { 59 + for (const fw of ['HIPAA', 'GDPR', 'SOC2', 'CCPA'] as const) { 60 + for (const check of getFrameworkChecks(fw)) { 61 + expect(check.id).toBeTruthy(); 62 + expect(check.requirement).toBeTruthy(); 63 + expect(check.description).toBeTruthy(); 64 + expect(check.evidence).toBeTruthy(); 65 + expect(['pass', 'fail', 'partial', 'not-applicable']).toContain(check.status); 66 + } 67 + } 68 + }); 69 + }); 70 + 71 + describe('generateReport', () => { 72 + it('generates a report with all frameworks', () => { 73 + const report = generateReport(['HIPAA', 'GDPR', 'SOC2', 'CCPA']); 74 + expect(report.properties).toHaveLength(5); 75 + expect(report.checks.length).toBeGreaterThan(10); 76 + expect(report.generatedAt).toBeTruthy(); 77 + }); 78 + 79 + it('generates report for single framework', () => { 80 + const report = generateReport(['HIPAA']); 81 + expect(report.checks.every(c => c.framework === 'HIPAA')).toBe(true); 82 + }); 83 + 84 + it('reports partial when some checks are partial', () => { 85 + const report = generateReport(['HIPAA']); 86 + // HIPAA has a partial check (audit controls) 87 + expect(report.overallStatus).toBe('partial'); 88 + }); 89 + 90 + it('reports compliant when all checks pass', () => { 91 + const report = generateReport(['CCPA']); 92 + // CCPA has all pass or not-applicable 93 + const hasFailOrPartial = report.checks.some(c => c.status === 'fail' || c.status === 'partial'); 94 + if (!hasFailOrPartial) { 95 + expect(report.overallStatus).toBe('compliant'); 96 + } 97 + }); 98 + }); 99 + 100 + describe('summarizChecks', () => { 101 + it('counts checks by status', () => { 102 + const checks = getFrameworkChecks('HIPAA'); 103 + const summary = summarizChecks(checks); 104 + expect(summary.pass).toBeGreaterThan(0); 105 + expect(typeof summary.fail).toBe('number'); 106 + expect(typeof summary.partial).toBe('number'); 107 + }); 108 + 109 + it('returns zero counts for empty array', () => { 110 + const summary = summarizChecks([]); 111 + expect(summary.pass).toBe(0); 112 + expect(summary.fail).toBe(0); 113 + }); 114 + }); 115 + 116 + describe('supportedFrameworks', () => { 117 + it('returns 4 frameworks', () => { 118 + expect(supportedFrameworks()).toHaveLength(4); 119 + }); 120 + 121 + it('all have id and name', () => { 122 + for (const fw of supportedFrameworks()) { 123 + expect(fw.id).toBeTruthy(); 124 + expect(fw.name).toBeTruthy(); 125 + } 126 + }); 127 + }); 128 + 129 + describe('allPropertiesVerified', () => { 130 + it('returns true when all properties are verified', () => { 131 + expect(allPropertiesVerified()).toBe(true); 132 + }); 133 + }); 134 + 135 + describe('nonPassingChecks', () => { 136 + it('finds non-passing HIPAA checks', () => { 137 + const nonPassing = nonPassingChecks('HIPAA'); 138 + expect(nonPassing.length).toBeGreaterThan(0); 139 + expect(nonPassing.every(c => c.status !== 'pass')).toBe(true); 140 + }); 141 + 142 + it('returns empty for fully-passing framework', () => { 143 + // CCPA only has pass and not-applicable 144 + const nonPassing = nonPassingChecks('CCPA'); 145 + const hasFailOrPartial = nonPassing.some(c => c.status === 'fail' || c.status === 'partial'); 146 + // not-applicable is also non-passing, so it may have entries 147 + expect(nonPassing.every(c => c.status !== 'pass')).toBe(true); 148 + }); 149 + }); 150 + });
+230
tests/rest-api.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + getApiRoutes, 4 + matchRoute, 5 + validateRequestBody, 6 + successResponse, 7 + errorResponse, 8 + notFound, 9 + badRequest, 10 + unauthorized, 11 + forbidden, 12 + createPagination, 13 + paginate, 14 + createWebhookConfig, 15 + webhookMatchesEvent, 16 + buildWebhookPayload, 17 + getWebhookEvents, 18 + } from '../src/lib/rest-api'; 19 + 20 + describe('rest-api', () => { 21 + describe('getApiRoutes', () => { 22 + it('returns routes', () => { 23 + const routes = getApiRoutes(); 24 + expect(routes.length).toBeGreaterThan(10); 25 + }); 26 + 27 + it('includes health check (unauthenticated)', () => { 28 + const health = getApiRoutes().find(r => r.path === '/api/v1/health'); 29 + expect(health).toBeDefined(); 30 + expect(health!.auth).toBe(false); 31 + }); 32 + 33 + it('all non-health routes require auth', () => { 34 + const routes = getApiRoutes().filter(r => r.path !== '/api/v1/health'); 35 + expect(routes.every(r => r.auth)).toBe(true); 36 + }); 37 + 38 + it('routes with params have param definitions', () => { 39 + const withParams = getApiRoutes().filter(r => r.path.includes(':')); 40 + for (const route of withParams) { 41 + expect(route.params.length).toBeGreaterThan(0); 42 + } 43 + }); 44 + }); 45 + 46 + describe('matchRoute', () => { 47 + const routes = getApiRoutes(); 48 + 49 + it('matches exact path', () => { 50 + const result = matchRoute('GET', '/api/v1/docs', routes); 51 + expect(result).not.toBeNull(); 52 + expect(result!.route.description).toBe('List documents'); 53 + }); 54 + 55 + it('extracts path parameters', () => { 56 + const result = matchRoute('GET', '/api/v1/docs/abc-123', routes); 57 + expect(result).not.toBeNull(); 58 + expect(result!.params.docId).toBe('abc-123'); 59 + }); 60 + 61 + it('matches by method', () => { 62 + const get = matchRoute('GET', '/api/v1/docs', routes); 63 + const post = matchRoute('POST', '/api/v1/docs', routes); 64 + expect(get!.route.description).toBe('List documents'); 65 + expect(post!.route.description).toBe('Create document'); 66 + }); 67 + 68 + it('returns null for no match', () => { 69 + expect(matchRoute('GET', '/api/v1/unknown', routes)).toBeNull(); 70 + }); 71 + 72 + it('returns null for wrong method', () => { 73 + expect(matchRoute('PATCH', '/api/v1/docs', routes)).toBeNull(); 74 + }); 75 + 76 + it('matches webhook routes', () => { 77 + const result = matchRoute('DELETE', '/api/v1/webhooks/wh-123', routes); 78 + expect(result).not.toBeNull(); 79 + expect(result!.params.webhookId).toBe('wh-123'); 80 + }); 81 + }); 82 + 83 + describe('validateRequestBody', () => { 84 + it('validates when all required fields present', () => { 85 + const result = validateRequestBody({ title: 'Test' }, ['title']); 86 + expect(result.valid).toBe(true); 87 + expect(result.missingFields).toHaveLength(0); 88 + }); 89 + 90 + it('reports missing fields', () => { 91 + const result = validateRequestBody({}, ['title', 'content']); 92 + expect(result.valid).toBe(false); 93 + expect(result.missingFields).toEqual(['title', 'content']); 94 + }); 95 + 96 + it('rejects empty string values', () => { 97 + const result = validateRequestBody({ title: '' }, ['title']); 98 + expect(result.valid).toBe(false); 99 + }); 100 + 101 + it('passes with no required fields', () => { 102 + expect(validateRequestBody({}, []).valid).toBe(true); 103 + }); 104 + }); 105 + 106 + describe('response helpers', () => { 107 + it('creates success response', () => { 108 + const resp = successResponse({ id: '123' }); 109 + expect(resp.success).toBe(true); 110 + expect(resp.data).toEqual({ id: '123' }); 111 + expect(resp.error).toBeNull(); 112 + }); 113 + 114 + it('creates success response with pagination', () => { 115 + const pagination = createPagination(1, 10, 100); 116 + const resp = successResponse([], pagination); 117 + expect(resp.pagination).not.toBeNull(); 118 + expect(resp.pagination!.totalPages).toBe(10); 119 + }); 120 + 121 + it('creates error response', () => { 122 + const resp = errorResponse(500, 'SERVER_ERROR', 'Something went wrong'); 123 + expect(resp.success).toBe(false); 124 + expect(resp.error!.status).toBe(500); 125 + }); 126 + 127 + it('creates not found', () => { 128 + const resp = notFound('Document'); 129 + expect(resp.error!.status).toBe(404); 130 + expect(resp.error!.message).toContain('Document'); 131 + }); 132 + 133 + it('creates bad request', () => { 134 + expect(badRequest('Invalid input').error!.status).toBe(400); 135 + }); 136 + 137 + it('creates unauthorized', () => { 138 + expect(unauthorized().error!.status).toBe(401); 139 + }); 140 + 141 + it('creates forbidden', () => { 142 + expect(forbidden().error!.status).toBe(403); 143 + }); 144 + }); 145 + 146 + describe('createPagination', () => { 147 + it('calculates total pages', () => { 148 + const p = createPagination(1, 10, 25); 149 + expect(p.totalPages).toBe(3); 150 + }); 151 + 152 + it('clamps page to minimum 1', () => { 153 + expect(createPagination(0, 10, 100).page).toBe(1); 154 + }); 155 + 156 + it('clamps perPage to 1-100', () => { 157 + expect(createPagination(1, 0, 100).perPage).toBe(1); 158 + expect(createPagination(1, 200, 100).perPage).toBe(100); 159 + }); 160 + 161 + it('handles zero total', () => { 162 + const p = createPagination(1, 10, 0); 163 + expect(p.totalPages).toBe(0); 164 + }); 165 + }); 166 + 167 + describe('paginate', () => { 168 + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 169 + 170 + it('returns first page', () => { 171 + const result = paginate(items, 1, 3); 172 + expect(result.items).toEqual([1, 2, 3]); 173 + expect(result.pagination.page).toBe(1); 174 + }); 175 + 176 + it('returns second page', () => { 177 + const result = paginate(items, 2, 3); 178 + expect(result.items).toEqual([4, 5, 6]); 179 + }); 180 + 181 + it('returns partial last page', () => { 182 + const result = paginate(items, 4, 3); 183 + expect(result.items).toEqual([10]); 184 + }); 185 + 186 + it('returns empty for out-of-range page', () => { 187 + const result = paginate(items, 100, 3); 188 + expect(result.items).toHaveLength(0); 189 + }); 190 + }); 191 + 192 + describe('webhooks', () => { 193 + it('creates webhook config', () => { 194 + const wh = createWebhookConfig('https://example.com/hook', ['doc.created'], 'secret123'); 195 + expect(wh.url).toBe('https://example.com/hook'); 196 + expect(wh.events).toEqual(['doc.created']); 197 + expect(wh.active).toBe(true); 198 + }); 199 + 200 + it('matches specific event', () => { 201 + const wh = createWebhookConfig('https://x.com', ['doc.created', 'doc.updated'], 's'); 202 + expect(webhookMatchesEvent(wh, 'doc.created')).toBe(true); 203 + expect(webhookMatchesEvent(wh, 'doc.deleted')).toBe(false); 204 + }); 205 + 206 + it('matches wildcard event', () => { 207 + const wh = createWebhookConfig('https://x.com', ['*'], 's'); 208 + expect(webhookMatchesEvent(wh, 'anything')).toBe(true); 209 + }); 210 + 211 + it('does not match when inactive', () => { 212 + const wh = { ...createWebhookConfig('https://x.com', ['*'], 's'), active: false }; 213 + expect(webhookMatchesEvent(wh, 'doc.created')).toBe(false); 214 + }); 215 + 216 + it('builds webhook payload', () => { 217 + const payload = buildWebhookPayload('doc.created', 'doc-123', 'encrypted-data'); 218 + expect(payload.event).toBe('doc.created'); 219 + expect(payload.docId).toBe('doc-123'); 220 + expect(payload.data).toBe('encrypted-data'); 221 + expect(payload.timestamp).toBeTruthy(); 222 + }); 223 + 224 + it('lists webhook events', () => { 225 + const events = getWebhookEvents(); 226 + expect(events.length).toBeGreaterThan(0); 227 + expect(events.every(e => e.event && e.description)).toBe(true); 228 + }); 229 + }); 230 + });
+224
tests/secure-embeds.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createEmbedConfig, 4 + embedUrl, 5 + embedSnippet, 6 + isAllowedOrigin, 7 + validateMessage, 8 + createKeyMessage, 9 + createReadyMessage, 10 + createErrorMessage, 11 + createResizeMessage, 12 + generateCSP, 13 + cspHeaderValue, 14 + validateEmbedConfig, 15 + parseEmbedParams, 16 + } from '../src/lib/secure-embeds'; 17 + 18 + describe('secure-embeds', () => { 19 + describe('createEmbedConfig', () => { 20 + it('creates default config for docs', () => { 21 + const config = createEmbedConfig('doc-123', 'docs'); 22 + expect(config.docId).toBe('doc-123'); 23 + expect(config.docType).toBe('docs'); 24 + expect(config.readOnly).toBe(true); 25 + expect(config.showToolbar).toBe(false); 26 + expect(config.showHeaders).toBe(false); 27 + expect(config.refreshInterval).toBe(0); 28 + }); 29 + 30 + it('shows headers by default for sheets', () => { 31 + const config = createEmbedConfig('sheet-1', 'sheets'); 32 + expect(config.showHeaders).toBe(true); 33 + }); 34 + 35 + it('accepts allowed origins', () => { 36 + const config = createEmbedConfig('doc-1', 'docs', ['https://example.com']); 37 + expect(config.allowedOrigins).toEqual(['https://example.com']); 38 + }); 39 + }); 40 + 41 + describe('embedUrl', () => { 42 + it('generates basic embed URL', () => { 43 + const config = createEmbedConfig('abc', 'docs'); 44 + const url = embedUrl(config); 45 + expect(url).toBe('/embed/docs/abc?toolbar=0&headers=0'); 46 + }); 47 + 48 + it('omits toolbar param when shown', () => { 49 + const config = { ...createEmbedConfig('abc', 'sheets'), showToolbar: true }; 50 + const url = embedUrl(config); 51 + expect(url).not.toContain('toolbar=0'); 52 + }); 53 + 54 + it('includes refresh interval', () => { 55 + const config = { ...createEmbedConfig('abc', 'docs'), refreshInterval: 30 }; 56 + expect(embedUrl(config)).toContain('refresh=30'); 57 + }); 58 + }); 59 + 60 + describe('embedSnippet', () => { 61 + it('generates iframe HTML', () => { 62 + const config = createEmbedConfig('abc', 'docs'); 63 + const snippet = embedSnippet('https://tools.example.com', config); 64 + expect(snippet).toContain('<iframe'); 65 + expect(snippet).toContain('src="https://tools.example.com/embed/docs/abc'); 66 + expect(snippet).toContain('sandbox="allow-scripts allow-same-origin"'); 67 + }); 68 + }); 69 + 70 + describe('isAllowedOrigin', () => { 71 + it('denies all when no origins specified', () => { 72 + expect(isAllowedOrigin('https://example.com', [])).toBe(false); 73 + }); 74 + 75 + it('allows matching origin', () => { 76 + expect(isAllowedOrigin('https://example.com', ['https://example.com'])).toBe(true); 77 + }); 78 + 79 + it('rejects non-matching origin', () => { 80 + expect(isAllowedOrigin('https://evil.com', ['https://example.com'])).toBe(false); 81 + }); 82 + 83 + it('allows wildcard', () => { 84 + expect(isAllowedOrigin('https://anything.com', ['*'])).toBe(true); 85 + }); 86 + }); 87 + 88 + describe('validateMessage', () => { 89 + it('validates key message', () => { 90 + const result = validateMessage({ type: 'embed-key', key: 'abc123' }); 91 + expect(result.valid).toBe(true); 92 + expect(result.message!.type).toBe('embed-key'); 93 + }); 94 + 95 + it('validates ready message', () => { 96 + expect(validateMessage({ type: 'embed-ready' }).valid).toBe(true); 97 + }); 98 + 99 + it('validates error message', () => { 100 + expect(validateMessage({ type: 'embed-error', error: 'fail' }).valid).toBe(true); 101 + }); 102 + 103 + it('validates resize message', () => { 104 + expect(validateMessage({ type: 'embed-resize', width: 800, height: 600 }).valid).toBe(true); 105 + }); 106 + 107 + it('rejects null', () => { 108 + expect(validateMessage(null).valid).toBe(false); 109 + }); 110 + 111 + it('rejects unknown type', () => { 112 + expect(validateMessage({ type: 'unknown' }).valid).toBe(false); 113 + }); 114 + 115 + it('rejects key message without key', () => { 116 + expect(validateMessage({ type: 'embed-key' }).valid).toBe(false); 117 + }); 118 + 119 + it('rejects key message with empty key', () => { 120 + expect(validateMessage({ type: 'embed-key', key: '' }).valid).toBe(false); 121 + }); 122 + }); 123 + 124 + describe('message creators', () => { 125 + it('creates key message', () => { 126 + const msg = createKeyMessage('secret'); 127 + expect(msg.type).toBe('embed-key'); 128 + expect(msg.key).toBe('secret'); 129 + }); 130 + 131 + it('creates ready message', () => { 132 + expect(createReadyMessage().type).toBe('embed-ready'); 133 + }); 134 + 135 + it('creates error message', () => { 136 + const msg = createErrorMessage('oops'); 137 + expect(msg.type).toBe('embed-error'); 138 + expect(msg.error).toBe('oops'); 139 + }); 140 + 141 + it('creates resize message', () => { 142 + const msg = createResizeMessage(800, 600); 143 + expect(msg.width).toBe(800); 144 + expect(msg.height).toBe(600); 145 + }); 146 + }); 147 + 148 + describe('generateCSP', () => { 149 + it('blocks all frames when no origins', () => { 150 + const csp = generateCSP([]); 151 + expect(csp.frameAncestors).toEqual(["'none'"]); 152 + }); 153 + 154 + it('allows specified origins', () => { 155 + const csp = generateCSP(['https://example.com']); 156 + expect(csp.frameAncestors).toEqual(['https://example.com']); 157 + }); 158 + 159 + it('includes self for scripts', () => { 160 + const csp = generateCSP([]); 161 + expect(csp.scriptSrc).toContain("'self'"); 162 + }); 163 + 164 + it('allows WebSocket connections', () => { 165 + const csp = generateCSP([]); 166 + expect(csp.connectSrc).toContain('wss:'); 167 + }); 168 + }); 169 + 170 + describe('cspHeaderValue', () => { 171 + it('formats CSP as header string', () => { 172 + const csp = generateCSP(['https://example.com']); 173 + const header = cspHeaderValue(csp); 174 + expect(header).toContain('frame-ancestors https://example.com'); 175 + expect(header).toContain("script-src 'self'"); 176 + }); 177 + }); 178 + 179 + describe('validateEmbedConfig', () => { 180 + it('validates good config', () => { 181 + const config = createEmbedConfig('abc', 'docs'); 182 + expect(validateEmbedConfig(config).valid).toBe(true); 183 + }); 184 + 185 + it('rejects missing doc ID', () => { 186 + const config = createEmbedConfig('', 'docs'); 187 + expect(validateEmbedConfig(config).valid).toBe(false); 188 + }); 189 + 190 + it('rejects negative refresh interval', () => { 191 + const config = { ...createEmbedConfig('abc', 'docs'), refreshInterval: -1 }; 192 + expect(validateEmbedConfig(config).valid).toBe(false); 193 + }); 194 + 195 + it('rejects excessive refresh interval', () => { 196 + const config = { ...createEmbedConfig('abc', 'docs'), refreshInterval: 9999 }; 197 + expect(validateEmbedConfig(config).valid).toBe(false); 198 + }); 199 + }); 200 + 201 + describe('parseEmbedParams', () => { 202 + it('parses toolbar param', () => { 203 + expect(parseEmbedParams({ toolbar: '0' }).showToolbar).toBe(false); 204 + expect(parseEmbedParams({ toolbar: '1' }).showToolbar).toBe(true); 205 + }); 206 + 207 + it('parses headers param', () => { 208 + expect(parseEmbedParams({ headers: '0' }).showHeaders).toBe(false); 209 + }); 210 + 211 + it('parses refresh param', () => { 212 + expect(parseEmbedParams({ refresh: '30' }).refreshInterval).toBe(30); 213 + }); 214 + 215 + it('ignores invalid refresh', () => { 216 + expect(parseEmbedParams({ refresh: 'abc' }).refreshInterval).toBeUndefined(); 217 + }); 218 + 219 + it('returns empty for no params', () => { 220 + const result = parseEmbedParams({}); 221 + expect(result.showToolbar).toBeUndefined(); 222 + }); 223 + }); 224 + });