diff --git a/app.json b/app.json index e3dc6b65..f41c9b23 100644 --- a/app.json +++ b/app.json @@ -47,7 +47,16 @@ "web": { "favicon": "./assets/subtrackr-icon.png", "name": "SubTrackr - Subscription Management with Crypto Payments", - "description": "Manage your subscriptions with Web3 crypto payments" + "description": "Manage your subscriptions with Web3 crypto payments", + "meta": { + "http-equiv": { + "Content-Security-Policy": "default-src 'self'; script-src 'self' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.subtrackr.app; font-src 'self' https://fonts.gstatic.com; object-src 'none'; media-src 'none'; frame-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; upgrade-insecure-requests", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()" + } + } }, "extra": { "eas": { diff --git a/app/screens/ChurnPredictionScreen.tsx b/app/screens/ChurnPredictionScreen.tsx index d07b3f91..51b3fcfb 100644 --- a/app/screens/ChurnPredictionScreen.tsx +++ b/app/screens/ChurnPredictionScreen.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native'; import { colors, spacing } from '../../src/utils/constants'; import { Card } from '../../src/components/common/Card'; -import { PredictionService, ChurnPrediction } from '../../backend/services/predictionService'; +import { PredictionService, ChurnPrediction, RiskFactor } from '../../backend/services/analytics/predictionService'; const ChurnPredictionScreen = () => { const [loading, setLoading] = useState(true); @@ -78,13 +78,13 @@ const ChurnPredictionScreen = () => { Key Risk Factors - {prediction.riskFactors.map((factor, index) => ( + {prediction.riskFactors.map((factor: RiskFactor, index: number) => ( {factor.factor .split('_') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ')} diff --git a/app/screens/InvoiceAnalyticsScreen.tsx b/app/screens/InvoiceAnalyticsScreen.tsx new file mode 100644 index 00000000..f66a95aa --- /dev/null +++ b/app/screens/InvoiceAnalyticsScreen.tsx @@ -0,0 +1,104 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; +import { InvoiceStatus } from '../../src/types/invoice'; + +export const InvoiceAnalyticsScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { invoices } = useInvoiceStore(); + + const totalInvoices = invoices.length; + const sentInvoices = invoices.filter(i => i.status === InvoiceStatus.SENT).length; + const paidInvoices = invoices.filter(i => i.status === InvoiceStatus.PAID).length; + const draftInvoices = invoices.filter(i => i.status === InvoiceStatus.DRAFT).length; + const voidInvoices = invoices.filter(i => i.status === InvoiceStatus.VOID).length; + + const totalRevenue = invoices + .filter(i => i.status === InvoiceStatus.PAID) + .reduce((sum, i) => sum + i.total, 0); + + return ( + + + + Invoice Analytics + Track delivery and payment performance + + + + + Total Collected + ${(totalRevenue / 100).toFixed(2)} + + + Total Invoices + {totalInvoices} + + + + + Status Breakdown + + Paid + {paidInvoices} + + + Sent (Pending) + {sentInvoices} + + + Drafts + {draftInvoices} + + + Voided + {voidInvoices} + + + + + Automated Delivery + + {sentInvoices + paidInvoices > 0 ? "100% of sent invoices were delivered automatically." : "No invoices delivered yet."} + + + + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + summaryContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.md, + }, + summaryCard: { flex: 1, alignItems: 'center' }, + summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + summaryValue: { ...typography.h2, color: colors.text.primary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, + statRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + statLabel: { ...typography.body, color: colors.text.primary }, + statValue: { ...typography.body, fontWeight: 'bold', color: colors.text.primary }, + label: { ...typography.body, color: colors.textSecondary }, + }); +} diff --git a/app/screens/InvoiceCustomizationScreen.tsx b/app/screens/InvoiceCustomizationScreen.tsx new file mode 100644 index 00000000..6177c334 --- /dev/null +++ b/app/screens/InvoiceCustomizationScreen.tsx @@ -0,0 +1,141 @@ +import React, { useState, useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, TouchableOpacity, TextInput } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; + +export const InvoiceCustomizationScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const { config, templates, setInvoiceBranding, setDefaultTemplate } = useInvoiceStore(); + const branding = config.defaultBranding || { logoUrl: '', primaryColor: '#000000', fontFamily: 'Inter' }; + + const [logoUrl, setLogoUrl] = useState(branding.logoUrl || ''); + const [primaryColor, setPrimaryColor] = useState(branding.primaryColor || '#000000'); + const [fontFamily, setFontFamily] = useState(branding.fontFamily || 'Inter'); + const [selectedTemplate, setSelectedTemplate] = useState(config.defaultTemplateId || templates[0]?.id); + + const handleSave = () => { + setInvoiceBranding({ logoUrl, primaryColor, fontFamily }); + if (selectedTemplate) setDefaultTemplate(selectedTemplate); + alert('Invoice branding saved!'); + }; + + return ( + + + + Invoice Customization + Customize your per-tenant branding + + + + Branding + Logo URL + + + Primary Color + + + Font Family + + + + + Template Selection + {templates.map(tpl => ( + setSelectedTemplate(tpl.id)} + > + {tpl.name} + {tpl.layout} layout + + ))} + + + + Preview + + INVOICE + {logoUrl ? Logo: {logoUrl} : null} + This is a preview of your custom invoice styling. + + + + + Save Customization + + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, + label: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + input: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: borderRadius.sm, + padding: spacing.sm, + marginBottom: spacing.md, + color: colors.text.primary, + }, + templateItem: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.sm, + }, + templateName: { ...typography.body, fontWeight: 'bold', color: colors.text.primary }, + previewBox: { + borderWidth: 2, + borderRadius: borderRadius.md, + padding: spacing.lg, + minHeight: 150, + backgroundColor: '#fff', + }, + actionButton: { + marginHorizontal: spacing.lg, + marginBottom: spacing.xl, + backgroundColor: colors.primary, + padding: spacing.md, + borderRadius: borderRadius.md, + alignItems: 'center' + }, + actionButtonText: { + ...typography.button, + color: colors.text.inverse + }, + }); +} diff --git a/app/screens/InvoiceMarketplaceScreen.tsx b/app/screens/InvoiceMarketplaceScreen.tsx new file mode 100644 index 00000000..323b2c78 --- /dev/null +++ b/app/screens/InvoiceMarketplaceScreen.tsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, TouchableOpacity } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; + +const MARKETPLACE_TEMPLATES = [ + { id: 'tpl-1', name: 'Standard Layout', layout: 'standard', price: 'Free' }, + { id: 'tpl-2', name: 'Modern Minimalist', layout: 'modern', price: 'Free' }, + { id: 'tpl-3', name: 'Premium Corporate', layout: 'premium', price: '$4.99' }, + { id: 'tpl-4', name: 'Creative Studio', layout: 'creative', price: '$2.99' }, +]; + +export const InvoiceMarketplaceScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { templates, addTemplate } = useInvoiceStore(); + + const handleInstall = (template: any) => { + const isInstalled = templates.some(t => t.id === template.id); + if (isInstalled) { + alert('Template already installed!'); + return; + } + + // Simulate purchase/installation + addTemplate({ id: template.id, name: template.name, layout: template.layout as any }); + alert(`${template.name} has been added to your templates!`); + }; + + return ( + + + + Template Marketplace + Discover and install new invoice layouts + + + {MARKETPLACE_TEMPLATES.map(tpl => { + const isInstalled = templates.some(t => t.id === tpl.id); + + return ( + + + + {tpl.name} + Style: {tpl.layout} + + + {tpl.price} + handleInstall(tpl)} + disabled={isInstalled} + > + {isInstalled ? 'Installed' : 'Get'} + + + + + ); + })} + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + info: { flex: 1 }, + templateName: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.xs }, + templateDesc: { ...typography.caption, color: colors.textSecondary }, + action: { alignItems: 'flex-end' }, + price: { ...typography.body, fontWeight: 'bold', color: colors.text.primary, marginBottom: spacing.xs }, + button: { + backgroundColor: colors.primary, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + }, + buttonInstalled: { + backgroundColor: colors.border.default, + }, + buttonText: { + ...typography.button, + color: colors.text.inverse, + fontSize: 12 + } + }); +} diff --git a/backend/services/billing/invoiceCustomizationService.ts b/backend/services/billing/invoiceCustomizationService.ts new file mode 100644 index 00000000..41e8bcbe --- /dev/null +++ b/backend/services/billing/invoiceCustomizationService.ts @@ -0,0 +1,52 @@ +import { Invoice, InvoiceBranding } from '../../../src/types/invoice'; +import { useInvoiceStore } from '../../../src/store/invoiceStore'; +import { presentLocalNotification } from '../../../src/services/notificationService'; + +export class InvoiceCustomizationService { + /** + * Simulates generating a PDF with per-tenant branding applied. + */ + static async generateInvoicePdf(invoice: Invoice, branding?: InvoiceBranding): Promise { + const configBranding = branding || invoice.branding || useInvoiceStore.getState().config.defaultBranding; + const templateId = invoice.templateId || 'tpl-1'; + + // In a real implementation, we would use pdfkit, puppeteer, or a service like PDFMonkey here. + // For now, we simulate generation. + console.log(`Generating PDF for ${invoice.invoiceNumber}...`); + console.log(`Applying Template ID: ${templateId}`); + if (configBranding) { + console.log(`Applying Branding: Logo=${configBranding.logoUrl}, PrimaryColor=${configBranding.primaryColor}, Font=${configBranding.fontFamily}`); + } + + // Simulate delay + await new Promise(resolve => setTimeout(resolve, 800)); + + const simulatedPdfUrl = `https://cdn.subtrackr.app/invoices/${invoice.id}.pdf`; + return simulatedPdfUrl; + } + + /** + * Automates the delivery of an invoice via email and pushes a notification. + */ + static async deliverInvoice(invoiceId: string, recipientEmail: string): Promise { + try { + const store = useInvoiceStore.getState(); + const invoice = store.invoices.find(i => i.id === invoiceId); + + if (!invoice) throw new Error('Invoice not found'); + + // Generate the branded PDF + const pdfUrl = await this.generateInvoicePdf(invoice); + + console.log(`Sending email to ${recipientEmail} with attachment ${pdfUrl}...`); + + // Update the invoice status + await store.sendInvoice(invoiceId, recipientEmail); + + return true; + } catch (e) { + console.error('Automated invoice delivery failed:', e); + return false; + } + } +} diff --git a/backend/services/shared/__tests__/cspMiddleware.test.ts b/backend/services/shared/__tests__/cspMiddleware.test.ts new file mode 100644 index 00000000..27fd57c8 --- /dev/null +++ b/backend/services/shared/__tests__/cspMiddleware.test.ts @@ -0,0 +1,669 @@ +/** + * Tests for Issue #1004 – XSS Prevention with Content Security Policy + */ + +import { + buildCspHeader, + buildSecurityHeaders, + createCspMiddleware, + sanitizeHtml, + sanitizeObject, + createXssSanitizerMiddleware, + generateCspNonce, + buildNoncePolicy, + DEFAULT_CSP_POLICY, + HTML_CSP_POLICY, +} from '../cspMiddleware'; + +import type { + CspPolicy, + SanitizeOptions, + SecurityHeaders, + SanitizableRequest, + XssSanitizerMiddlewareOptions, +} from '../cspMiddleware'; + +// ───────────────────────────────────────────────────────────────────────────── +// buildCspHeader() +// ───────────────────────────────────────────────────────────────────────────── + +describe('buildCspHeader()', () => { + it('builds a header string from an array directive', () => { + const header = buildCspHeader({ defaultSrc: ["'self'"] }); + expect(header).toBe("default-src 'self'"); + }); + + it('separates multiple sources with spaces', () => { + const header = buildCspHeader({ scriptSrc: ["'self'", 'https://cdn.example.com'] }); + expect(header).toBe("script-src 'self' https://cdn.example.com"); + }); + + it('includes boolean-true directives without a value', () => { + const header = buildCspHeader({ upgradeInsecureRequests: true }); + expect(header).toBe('upgrade-insecure-requests'); + }); + + it('excludes boolean-false directives', () => { + const header = buildCspHeader({ blockAllMixedContent: false }); + expect(header).toBe(''); + }); + + it('separates multiple directives with semicolons', () => { + const header = buildCspHeader({ + defaultSrc: ["'none'"], + connectSrc: ["'self'"], + }); + expect(header).toContain("default-src 'none'"); + expect(header).toContain("connect-src 'self'"); + expect(header).toContain(';'); + }); + + it('returns an empty string for an empty policy', () => { + const header = buildCspHeader({}); + expect(header).toBe(''); + }); + + it('handles all supported directives', () => { + const policy: CspPolicy = { + defaultSrc: ["'none'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'"], + imgSrc: ['https:'], + connectSrc: ["'self'"], + fontSrc: ["'none'"], + objectSrc: ["'none'"], + mediaSrc: ["'none'"], + frameSrc: ["'none'"], + frameAncestors: ["'none'"], + formAction: ["'self'"], + baseUri: ["'self'"], + upgradeInsecureRequests: true, + }; + const header = buildCspHeader(policy); + expect(header).toContain('default-src'); + expect(header).toContain('script-src'); + expect(header).toContain('upgrade-insecure-requests'); + }); + + it('omits empty-array directives', () => { + const header = buildCspHeader({ scriptSrc: [] }); + expect(header).toBe(''); + }); + + it('handles report-uri directive', () => { + const header = buildCspHeader({ reportUri: ['/csp-report'] }); + expect(header).toBe('report-uri /csp-report'); + }); + + it('handles worker-src directive', () => { + const header = buildCspHeader({ workerSrc: ["'self'", 'blob:'] }); + expect(header).toBe("worker-src 'self' blob:"); + }); + + it('combines boolean and array directives in one policy', () => { + const header = buildCspHeader({ + defaultSrc: ["'self'"], + upgradeInsecureRequests: true, + blockAllMixedContent: true, + }); + expect(header).toContain("default-src 'self'"); + expect(header).toContain('upgrade-insecure-requests'); + expect(header).toContain('block-all-mixed-content'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DEFAULT_CSP_POLICY +// ───────────────────────────────────────────────────────────────────────────── + +describe('DEFAULT_CSP_POLICY', () => { + it("sets default-src to 'none'", () => { + expect(DEFAULT_CSP_POLICY.defaultSrc).toEqual(["'none'"]); + }); + + it("sets script-src to 'none'", () => { + expect(DEFAULT_CSP_POLICY.scriptSrc).toEqual(["'none'"]); + }); + + it("sets frame-ancestors to 'none'", () => { + expect(DEFAULT_CSP_POLICY.frameAncestors).toEqual(["'none'"]); + }); + + it('enables upgradeInsecureRequests', () => { + expect(DEFAULT_CSP_POLICY.upgradeInsecureRequests).toBe(true); + }); + + it("allows connect-src 'self'", () => { + expect(DEFAULT_CSP_POLICY.connectSrc).toContain("'self'"); + }); + + it('produces a non-empty CSP string', () => { + expect(buildCspHeader(DEFAULT_CSP_POLICY).length).toBeGreaterThan(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// HTML_CSP_POLICY +// ───────────────────────────────────────────────────────────────────────────── + +describe('HTML_CSP_POLICY', () => { + it("sets default-src to 'self'", () => { + expect(HTML_CSP_POLICY.defaultSrc).toContain("'self'"); + }); + + it('allows strict-dynamic in script-src', () => { + expect(HTML_CSP_POLICY.scriptSrc).toContain("'strict-dynamic'"); + }); + + it("sets object-src to 'none'", () => { + expect(HTML_CSP_POLICY.objectSrc).toEqual(["'none'"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// buildSecurityHeaders() +// ───────────────────────────────────────────────────────────────────────────── + +describe('buildSecurityHeaders()', () => { + let headers: SecurityHeaders; + + beforeEach(() => { + headers = buildSecurityHeaders(); + }); + + it('includes Content-Security-Policy', () => { + expect(headers['Content-Security-Policy']).toBeTruthy(); + }); + + it('sets X-Content-Type-Options to nosniff', () => { + expect(headers['X-Content-Type-Options']).toBe('nosniff'); + }); + + it('sets X-Frame-Options to DENY', () => { + expect(headers['X-Frame-Options']).toBe('DENY'); + }); + + it('sets X-XSS-Protection', () => { + expect(headers['X-XSS-Protection']).toBe('1; mode=block'); + }); + + it('sets Referrer-Policy to strict-origin-when-cross-origin', () => { + expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin'); + }); + + it('sets Strict-Transport-Security with preload', () => { + expect(headers['Strict-Transport-Security']).toContain('preload'); + expect(headers['Strict-Transport-Security']).toContain('max-age=31536000'); + expect(headers['Strict-Transport-Security']).toContain('includeSubDomains'); + }); + + it('sets Permissions-Policy', () => { + expect(headers['Permissions-Policy']).toContain('geolocation=()'); + expect(headers['Permissions-Policy']).toContain('microphone=()'); + expect(headers['Permissions-Policy']).toContain('camera=()'); + }); + + it('sets Cross-Origin-Opener-Policy to same-origin', () => { + expect(headers['Cross-Origin-Opener-Policy']).toBe('same-origin'); + }); + + it('sets Cross-Origin-Resource-Policy to same-origin', () => { + expect(headers['Cross-Origin-Resource-Policy']).toBe('same-origin'); + }); + + it('sets Cross-Origin-Embedder-Policy to require-corp', () => { + expect(headers['Cross-Origin-Embedder-Policy']).toBe('require-corp'); + }); + + it('accepts a custom policy', () => { + const custom = buildSecurityHeaders({ connectSrc: ["'self'", 'https://rpc.example.com'] }); + expect(custom['Content-Security-Policy']).toContain('https://rpc.example.com'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// createCspMiddleware() +// ───────────────────────────────────────────────────────────────────────────── + +describe('createCspMiddleware()', () => { + it('calls next()', () => { + const middleware = createCspMiddleware(); + const next = jest.fn(); + const res = { setHeader: jest.fn() }; + middleware({}, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('sets all security headers on the response', () => { + const middleware = createCspMiddleware(); + const setHeader = jest.fn(); + middleware({}, { setHeader }, () => {}); + expect(setHeader).toHaveBeenCalledWith('Content-Security-Policy', expect.any(String)); + expect(setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); + expect(setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY'); + expect(setHeader).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block'); + expect(setHeader).toHaveBeenCalledWith('Referrer-Policy', expect.any(String)); + expect(setHeader).toHaveBeenCalledWith('Strict-Transport-Security', expect.any(String)); + }); + + it('applies a custom policy', () => { + const custom: CspPolicy = { connectSrc: ["'self'", 'https://custom.example.com'] }; + const middleware = createCspMiddleware(custom); + const setHeader = jest.fn(); + middleware({}, { setHeader }, () => {}); + const cspCall = (setHeader.mock.calls as [string, string][]).find( + ([name]) => name === 'Content-Security-Policy', + ); + expect(cspCall?.[1]).toContain('https://custom.example.com'); + }); + + it('uses DEFAULT_CSP_POLICY when no policy is provided', () => { + const middleware = createCspMiddleware(); + const setHeader = jest.fn(); + middleware({}, { setHeader }, () => {}); + const cspCall = (setHeader.mock.calls as [string, string][]).find( + ([name]) => name === 'Content-Security-Policy', + ); + expect(cspCall?.[1]).toContain("default-src 'none'"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// sanitizeHtml() +// ───────────────────────────────────────────────────────────────────────────── + +describe('sanitizeHtml()', () => { + // ── Script tag removal ──────────────────────────────────────────────────── + + it('removes a basic script tag', () => { + expect(sanitizeHtml('Hello')).not.toContain(' { + const input = 'alert("xss")safe'; + expect(sanitizeHtml(input)).not.toContain('script'); + }); + + it('removes script tag with src attribute', () => { + const input = ''; + expect(sanitizeHtml(input)).not.toContain('script'); + }); + + // ── Event handler removal ───────────────────────────────────────────────── + + it('removes onclick handler', () => { + expect(sanitizeHtml('
text
')).not.toContain('onclick'); + }); + + it('removes onerror handler', () => { + expect(sanitizeHtml('')).not.toContain('onerror'); + }); + + it('removes onload handler', () => { + expect(sanitizeHtml('')).not.toContain('onload'); + }); + + it('removes event handlers with single-quote delimiters', () => { + expect(sanitizeHtml("
")).not.toContain('onclick'); + }); + + // ── JavaScript URI ──────────────────────────────────────────────────────── + + it('removes javascript: URI', () => { + expect(sanitizeHtml('click')).not.toContain('javascript:'); + }); + + it('removes javascript: URI with spaces', () => { + expect(sanitizeHtml('click')).not.toContain('javascript'); + }); + + // ── vbscript URI ────────────────────────────────────────────────────────── + + it('removes vbscript: URI', () => { + expect(sanitizeHtml('click')).not.toContain('vbscript:'); + }); + + // ── iframe injection ────────────────────────────────────────────────────── + + it('removes iframe tags', () => { + expect(sanitizeHtml('')).not.toContain('iframe'); + }); + + // ── SVG injection ───────────────────────────────────────────────────────── + + it('removes SVG tags', () => { + expect(sanitizeHtml('')).not.toContain(' { + expect(sanitizeHtml('background: expression(alert(1))')).not.toContain('expression('); + }); + + // ── Safe content preservation ───────────────────────────────────────────── + + it('preserves plain text', () => { + const safe = 'Hello, World! This is a safe string.'; + const result = sanitizeHtml(safe); + expect(result).toContain('Hello'); + expect(result).toContain('World'); + }); + + it('strips tags but preserves text content', () => { + const result = sanitizeHtml('Bold text'); + expect(result).toContain('Bold'); + expect(result).toContain('text'); + expect(result).not.toContain(''); + }); + + // ── Entity encoding ─────────────────────────────────────────────────────── + + it('encodes < and > by default', () => { + const result = sanitizeHtml('3 < 5 and 5 > 3'); + expect(result).toContain('<'); + expect(result).toContain('>'); + }); + + it('encodes & by default', () => { + const result = sanitizeHtml('AT&T'); + expect(result).toContain('&'); + }); + + it('encodes double quotes by default', () => { + const result = sanitizeHtml('Say "hello"'); + expect(result).toContain('"'); + }); + + it('encodes single quotes by default', () => { + const result = sanitizeHtml("it's fine"); + expect(result).toContain('''); + }); + + it('skips encoding when encodeEntities is false', () => { + const result = sanitizeHtml('AT&T', { encodeEntities: false }); + expect(result).toBe('AT&T'); + }); + + // ── Extra patterns ──────────────────────────────────────────────────────── + + it('applies extra patterns', () => { + const result = sanitizeHtml('BADWORD hello', { + extraPatterns: [/BADWORD/g], + encodeEntities: false, + }); + expect(result).not.toContain('BADWORD'); + expect(result).toContain('hello'); + }); + + // ── Empty / edge inputs ─────────────────────────────────────────────────── + + it('returns an empty string for empty input', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('trims whitespace', () => { + expect(sanitizeHtml(' hello ')).toBe('hello'); + }); + + it('handles strings with only whitespace', () => { + expect(sanitizeHtml(' ')).toBe(''); + }); + + it('handles a string that is only dangerous content', () => { + const result = sanitizeHtml(''); + expect(result.trim()).toBe(''); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// sanitizeObject() +// ───────────────────────────────────────────────────────────────────────────── + +describe('sanitizeObject()', () => { + it('sanitizes string values in a flat object', () => { + const obj = { name: 'Alice', age: 30 }; + const result = sanitizeObject(obj) as typeof obj; + expect(result.name).not.toContain(' { + const obj = { user: { bio: '' } }; + const result = sanitizeObject(obj) as typeof obj; + expect((result.user as Record).bio).not.toContain('onerror'); + }); + + it('sanitizes string values in arrays', () => { + const arr = ['', 'safe']; + const result = sanitizeObject(arr) as string[]; + expect(result[0]).not.toContain(' { + const result = sanitizeObject(42); + expect(result).toBe(42); + }); + + it('passes through booleans unchanged', () => { + expect(sanitizeObject(true)).toBe(true); + expect(sanitizeObject(false)).toBe(false); + }); + + it('passes through null unchanged', () => { + expect(sanitizeObject(null)).toBeNull(); + }); + + it('strips function values from objects', () => { + const obj = { fn: () => 'evil', safe: 'hello' }; + const result = sanitizeObject(obj) as Record; + expect(result.fn).toBeUndefined(); + expect(result.safe).toBeTruthy(); + }); + + it('handles deeply nested objects without stack overflow', () => { + // Build a moderately deep object (within the MAX_DEPTH limit) + let deep: Record = { value: '' }; + for (let i = 0; i < 9; i++) deep = { nested: deep }; + expect(() => sanitizeObject(deep)).not.toThrow(); + }); + + it('handles arrays of objects', () => { + const arr = [{ name: 'Alice' }, { name: '' }]; + const result = sanitizeObject(arr) as Array>; + expect(result[0].name).not.toContain(''); + expect(result[1].name).not.toContain(' { + const obj = { text: 'AT&T' }; + const result = sanitizeObject(obj, { encodeEntities: false }) as typeof obj; + expect(result.text).toBe('AT&T'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// createXssSanitizerMiddleware() +// ───────────────────────────────────────────────────────────────────────────── + +describe('createXssSanitizerMiddleware()', () => { + it('calls next()', () => { + const middleware = createXssSanitizerMiddleware(); + const next = jest.fn(); + middleware({ body: { name: 'Alice' } }, {}, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('sanitizes all body fields by default', () => { + const middleware = createXssSanitizerMiddleware(); + const req: SanitizableRequest = { body: { name: 'Alice', age: 30 } }; + middleware(req, {}, () => {}); + const body = req.body as Record; + expect(body.name as string).not.toContain(' { + const middleware = createXssSanitizerMiddleware({ fields: ['description'] }); + const req: SanitizableRequest = { + body: { + name: '', + description: 'bold', + }, + }; + middleware(req, {}, () => {}); + const body = req.body as Record; + // 'description' should be sanitized + expect(body.description).not.toContain(''); + // 'name' is NOT in the fields list and should NOT be mutated + expect(body.name).toBe(''); + }); + + it('sanitizes query params when sanitizeQuery is true', () => { + const middleware = createXssSanitizerMiddleware({ sanitizeQuery: true }); + const req: SanitizableRequest = { query: { search: '' } }; + middleware(req, {}, () => {}); + expect(req.query?.search as string).not.toContain(' { + const middleware = createXssSanitizerMiddleware({ sanitizeQuery: true }); + const req: SanitizableRequest = { params: { id: '1' } }; + middleware(req, {}, () => {}); + expect(req.params?.id as string).not.toContain(''); + }); + + it('does not mutate query when sanitizeQuery is false (default)', () => { + const middleware = createXssSanitizerMiddleware(); + const req: SanitizableRequest = { query: { search: '' } }; + middleware(req, {}, () => {}); + // Query should be unchanged since sanitizeQuery defaults to false + expect(req.query?.search).toBe(''); + }); + + it('handles undefined body gracefully', () => { + const middleware = createXssSanitizerMiddleware(); + const req: SanitizableRequest = { body: undefined }; + expect(() => middleware(req, {}, () => {})).not.toThrow(); + }); + + it('handles null body gracefully', () => { + const middleware = createXssSanitizerMiddleware(); + const req: SanitizableRequest = { body: null as unknown as undefined }; + expect(() => middleware(req, {}, () => {})).not.toThrow(); + }); + + it('handles body that is not an object', () => { + const middleware = createXssSanitizerMiddleware(); + // Body as a primitive (unusual but defensive check) + const req = { body: 'raw string' } as unknown as SanitizableRequest; + expect(() => middleware(req, {}, () => {})).not.toThrow(); + }); + + it('forwards sanitizeOptions to sanitizeHtml', () => { + const opts: XssSanitizerMiddlewareOptions = { + sanitizeOptions: { encodeEntities: false }, + }; + const middleware = createXssSanitizerMiddleware(opts); + const req: SanitizableRequest = { body: { text: 'AT&T' } }; + middleware(req, {}, () => {}); + expect((req.body as Record).text).toBe('AT&T'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// generateCspNonce() +// ───────────────────────────────────────────────────────────────────────────── + +describe('generateCspNonce()', () => { + it('returns a non-empty string', () => { + const nonce = generateCspNonce(); + expect(typeof nonce).toBe('string'); + expect(nonce.length).toBeGreaterThan(0); + }); + + it('returns different values on each call', () => { + const a = generateCspNonce(); + const b = generateCspNonce(); + expect(a).not.toBe(b); + }); + + it('returns a URL-safe Base64 string (no +, /, or = padding)', () => { + for (let i = 0; i < 20; i++) { + const nonce = generateCspNonce(); + expect(nonce).not.toMatch(/[+/=]/); + } + }); + + it('has the expected length for 16 bytes base64url (22 chars)', () => { + const nonce = generateCspNonce(); + expect(nonce.length).toBe(22); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// buildNoncePolicy() +// ───────────────────────────────────────────────────────────────────────────── + +describe('buildNoncePolicy()', () => { + it("injects the nonce into script-src with 'nonce-' prefix", () => { + const nonce = 'abc123'; + const policy = buildNoncePolicy(nonce); + expect(policy.scriptSrc).toContain(`'nonce-${nonce}'`); + }); + + it('preserves the base script-src sources', () => { + const nonce = 'abc123'; + const policy = buildNoncePolicy(nonce, HTML_CSP_POLICY); + expect(policy.scriptSrc).toContain("'self'"); + expect(policy.scriptSrc).toContain("'strict-dynamic'"); + }); + + it('produces a valid CSP header string containing the nonce', () => { + const nonce = generateCspNonce(); + const policy = buildNoncePolicy(nonce); + const header = buildCspHeader(policy); + expect(header).toContain(`'nonce-${nonce}'`); + }); + + it('does not mutate the base policy', () => { + const base: CspPolicy = { scriptSrc: ["'self'"] }; + const original = [...(base.scriptSrc ?? [])]; + buildNoncePolicy('nonce123', base); + expect(base.scriptSrc).toEqual(original); + }); + + it('uses HTML_CSP_POLICY as default base', () => { + const policy = buildNoncePolicy('nonce123'); + expect(policy.defaultSrc).toEqual(HTML_CSP_POLICY.defaultSrc); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Integration: CSP middleware + XSS sanitizer used together +// ───────────────────────────────────────────────────────────────────────────── + +describe('Integration: CSP + XSS middleware chain', () => { + it('sets CSP headers and sanitizes the body in sequence', () => { + const cspMiddleware = createCspMiddleware(); + const xssMiddleware = createXssSanitizerMiddleware(); + + const setHeader = jest.fn(); + const res = { setHeader }; + const req: SanitizableRequest = { + body: { comment: 'Hello' }, + }; + + const nextCsp = jest.fn(() => xssMiddleware(req, res, () => {})); + cspMiddleware(req, res, nextCsp); + + // CSP headers were set + expect(setHeader).toHaveBeenCalledWith('Content-Security-Policy', expect.any(String)); + // Body was sanitized + const body = req.body as Record; + expect(body.comment).not.toContain(' = { + defaultSrc: 'default-src', + scriptSrc: 'script-src', + styleSrc: 'style-src', + imgSrc: 'img-src', + connectSrc: 'connect-src', + fontSrc: 'font-src', + objectSrc: 'object-src', + mediaSrc: 'media-src', + frameSrc: 'frame-src', + childSrc: 'child-src', + workerSrc: 'worker-src', + manifestSrc: 'manifest-src', + formAction: 'form-action', + frameAncestors: 'frame-ancestors', + baseUri: 'base-uri', + sandbox: 'sandbox', + reportUri: 'report-uri', + reportTo: 'report-to', + upgradeInsecureRequests: 'upgrade-insecure-requests', + blockAllMixedContent: 'block-all-mixed-content', + requireTrustedTypesFor: 'require-trusted-types-for', + trustedTypes: 'trusted-types', +}; + +/** + * Convert a `CspPolicy` object into a valid `Content-Security-Policy` header + * value string. + * + * @example + * buildCspHeader({ defaultSrc: ["'self'"], upgradeInsecureRequests: true }) + * // → "default-src 'self'; upgrade-insecure-requests" + */ +export function buildCspHeader(policy: CspPolicy): string { + const parts: string[] = []; + + for (const [key, value] of Object.entries(policy) as [keyof CspPolicy, unknown][]) { + const directive = DIRECTIVE_MAP[key]; + if (!directive) continue; + + if (typeof value === 'boolean') { + if (value) parts.push(directive); + } else if (Array.isArray(value) && value.length > 0) { + parts.push(`${directive} ${(value as string[]).join(' ')}`); + } + } + + return parts.join('; '); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Security headers +// ───────────────────────────────────────────────────────────────────────────── + +/** Security headers applied to every response alongside the CSP. */ +export interface SecurityHeaders { + 'Content-Security-Policy': string; + 'X-Content-Type-Options': string; + 'X-Frame-Options': string; + 'X-XSS-Protection': string; + 'Referrer-Policy': string; + 'Permissions-Policy': string; + 'Strict-Transport-Security': string; + 'Cross-Origin-Opener-Policy': string; + 'Cross-Origin-Resource-Policy': string; + 'Cross-Origin-Embedder-Policy': string; +} + +/** + * Build the full set of security headers for a response. + * + * @param policy - CSP policy; defaults to `DEFAULT_CSP_POLICY`. + */ +export function buildSecurityHeaders(policy: CspPolicy = DEFAULT_CSP_POLICY): SecurityHeaders { + return { + 'Content-Security-Policy': buildCspHeader(policy), + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CSP middleware +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shape of an Express-/Fastify-compatible middleware function that this module + * produces. We keep it dependency-free by typing req/res minimally. + */ +export type SecurityMiddleware = ( + req: { method?: string; url?: string }, + res: { setHeader: (name: string, value: string) => void }, + next: () => void, +) => void; + +/** + * Express/Fastify-compatible middleware that attaches the Content-Security-Policy + * header and all complementary security headers to **every** HTTP response. + * + * @param policy - Override the default CSP policy. + * + * @example + * app.use(createCspMiddleware()); + * // or with a custom policy: + * app.use(createCspMiddleware({ ...DEFAULT_CSP_POLICY, connectSrc: ["'self'", "https://rpc.subtrackr.app"] })); + */ +export function createCspMiddleware(policy: CspPolicy = DEFAULT_CSP_POLICY): SecurityMiddleware { + const headers = buildSecurityHeaders(policy); + + return function cspMiddleware(_req, res, next): void { + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); + } + next(); + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// XSS sanitizer +// ───────────────────────────────────────────────────────────────────────────── + +/** Options for the HTML sanitizer. */ +export interface SanitizeOptions { + /** + * Additional patterns to strip. Each entry is a RegExp with global flag. + * They are applied **after** the built-in HTML-entity and tag stripping. + */ + extraPatterns?: RegExp[]; + /** + * When true (default), HTML entities in the output are encoded: + * `<` → `<`, `>` → `>`, `&` → `&`, `"` → `"`, `'` → `'` + */ + encodeEntities?: boolean; +} + +// Characters that are dangerous in HTML contexts. +const HTML_ENTITY_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=', +}; + +// Patterns for common XSS vectors we want to strip outright. +const XSS_PATTERNS: RegExp[] = [ + // Script tags (any case, whitespace, encoded variants) + /[\s\S]*?<\/script>/gi, + // Event handlers: onclick="...", onerror='...', etc. + /\bon\w+\s*=\s*(['"`]?)[\s\S]*?\1/gi, + // javascript: URI + /javascript\s*:/gi, + // vbscript: URI + /vbscript\s*:/gi, + // data: URI (blocks data:text/html and data:application/xhtml+xml XSS vectors) + /data\s*:\s*(?:text\/html|application\/xhtml\+xml)/gi, + // expression() – IE CSS XSS + /expression\s*\(/gi, + // SVG/XML namespace attacks + /<\s*svg[\s\S]*?>/gi, + // Base tag injection + /<\s*base[\s\S]*?>/gi, + // Object/embed/applet tags + /<\s*(?:object|embed|applet)[\s\S]*?>/gi, + // Iframe injection + /<\s*iframe[\s\S]*?>/gi, + // Link tag with preload/import + /<\s*link[\s\S]*?>/gi, + // Meta refresh/redirect + /<\s*meta[\s\S]*?>/gi, + // Remaining HTML tags (catch-all for unknown tags after specific ones above) + /<[^>]+>/g, +]; + +/** + * Strip XSS vectors from a user-supplied string. + * + * The function: + * 1. Removes known dangerous HTML tags and attributes (event handlers, script, iframe, etc.) + * 2. Optionally encodes HTML special characters in the result (default: `true`). + * + * **Important**: This sanitizer is intended as a defence-in-depth measure. + * The primary XSS protection must always be Context-aware output encoding at + * the render layer (e.g. React JSX, Handlebars auto-escaping). + * + * @param input - Raw string from user input. + * @param opts - Optional tuning. + * @returns - Sanitized string safe to store and later render through an + * escaping template engine. + * + * @example + * sanitizeHtml('Hello') + * // → 'Hello' + * + * sanitizeHtml('Bold', { encodeEntities: false }) + * // → 'Bold' + */ +export function sanitizeHtml(input: string, opts: SanitizeOptions = {}): string { + const { extraPatterns = [], encodeEntities = true } = opts; + + let result = input; + + // Strip known XSS vectors + for (const pattern of XSS_PATTERNS) { + result = result.replace(pattern, ''); + } + + // Apply any caller-supplied extra patterns + for (const pattern of extraPatterns) { + result = result.replace(pattern, ''); + } + + // Encode remaining HTML special characters + if (encodeEntities) { + result = result.replace(/[&<>"'`=/]/g, (char) => HTML_ENTITY_MAP[char] ?? char); + } + + return result.trim(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Deep object sanitizer +// ───────────────────────────────────────────────────────────────────────────── + +/** Maximum recursion depth for object sanitization (prevents DoS via deeply nested input). */ +const MAX_DEPTH = 10; + +/** + * Recursively sanitize all `string` values within a plain object or array. + * + * Non-string primitives (numbers, booleans, null) are passed through unchanged. + * Functions and class instances are omitted from the output. + * + * @param obj - Value to sanitize. + * @param opts - Forwarded to `sanitizeHtml`. + * @param depth - Internal recursion depth counter; do not pass externally. + */ +export function sanitizeObject(obj: unknown, opts: SanitizeOptions = {}, depth = 0): unknown { + if (depth > MAX_DEPTH) return obj; + + if (typeof obj === 'string') { + return sanitizeHtml(obj, opts); + } + + if (Array.isArray(obj)) { + return obj.map((item) => sanitizeObject(item, opts, depth + 1)); + } + + if (obj !== null && typeof obj === 'object') { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (typeof value === 'function') continue; // strip callables + sanitized[key] = sanitizeObject(value, opts, depth + 1); + } + return sanitized; + } + + // Primitive (number, boolean, null, undefined) – return as-is + return obj; +} + +// ───────────────────────────────────────────────────────────────────────────── +// XSS sanitizer middleware +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Request shape that the XSS middleware works with. Typed minimally so this + * remains compatible with Express, Fastify, and plain `http.IncomingMessage` + * wrappers. + */ +export interface SanitizableRequest { + body?: unknown; + query?: Record; + params?: Record; +} + +/** + * Options for the XSS sanitizer middleware. + */ +export interface XssSanitizerMiddlewareOptions { + /** + * List of top-level body field names to sanitize. + * When omitted, **all** body fields are sanitized. + */ + fields?: string[]; + /** + * Whether to also sanitize `req.query` and `req.params`. + * @default false + */ + sanitizeQuery?: boolean; + /** Forwarded to `sanitizeHtml`. */ + sanitizeOptions?: SanitizeOptions; +} + +/** + * Middleware that sanitizes user-supplied string fields in `req.body` (and + * optionally `req.query` / `req.params`) before they reach route handlers. + * + * @param options - Tuning options. + * + * @example + * // Sanitize all body fields + * app.use(createXssSanitizerMiddleware()); + * + * // Sanitize only specific fields + * app.use(createXssSanitizerMiddleware({ fields: ['name', 'description'] })); + */ +export function createXssSanitizerMiddleware( + options: XssSanitizerMiddlewareOptions = {}, +): (req: SanitizableRequest, res: unknown, next: () => void) => void { + const { fields, sanitizeQuery = false, sanitizeOptions = {} } = options; + + return function xssSanitizerMiddleware(req, _res, next): void { + // Sanitize body + if (req.body !== undefined && req.body !== null && typeof req.body === 'object') { + if (fields && fields.length > 0) { + const body = req.body as Record; + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(body, field)) { + body[field] = sanitizeObject(body[field], sanitizeOptions); + } + } + } else { + req.body = sanitizeObject(req.body, sanitizeOptions); + } + } + + // Optionally sanitize query and params + if (sanitizeQuery) { + if (req.query) { + req.query = sanitizeObject(req.query, sanitizeOptions) as Record; + } + if (req.params) { + req.params = sanitizeObject(req.params, sanitizeOptions) as Record; + } + } + + next(); + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Nonce helpers (for inline scripts in HTML responses) +// ───────────────────────────────────────────────────────────────────────────── + +import { randomBytes } from 'crypto'; + +/** + * Generate a cryptographically random nonce string suitable for use in CSP + * `script-src 'nonce-'` directives. + * + * The nonce is Base64-URL encoded (no padding), 16 bytes → 22 characters. + * + * @example + * const nonce = generateCspNonce(); + * res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`); + * // In the HTML template: + * // + */ +export function generateCspNonce(): string { + return randomBytes(16).toString('base64url'); +} + +/** + * Build a `CspPolicy` with a per-request nonce injected into `scriptSrc`. + * + * @param nonce - Value returned by `generateCspNonce()`. + * @param base - Base policy to extend; defaults to `HTML_CSP_POLICY`. + */ +export function buildNoncePolicy(nonce: string, base: CspPolicy = HTML_CSP_POLICY): CspPolicy { + const existing = base.scriptSrc ?? ["'self'"]; + return { + ...base, + scriptSrc: [...existing, `'nonce-${nonce}'`], + }; +} diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts index 71b169d9..1ed89a34 100644 --- a/backend/services/shared/index.ts +++ b/backend/services/shared/index.ts @@ -50,3 +50,23 @@ export type { } from './apiResponse'; export type { TransactionStatus, AlertSeverity, AlertChannel, TransactionEvent, Metric, Alert, AlertRule, AlertChannelConfig, DashboardSnapshot } from './types'; export { MonitoringService, monitoringService } from './monitoring'; +export { + buildCspHeader, + buildSecurityHeaders, + createCspMiddleware, + sanitizeHtml, + sanitizeObject, + createXssSanitizerMiddleware, + generateCspNonce, + buildNoncePolicy, + DEFAULT_CSP_POLICY, + HTML_CSP_POLICY, +} from './cspMiddleware'; +export type { + CspPolicy, + SanitizeOptions, + SecurityHeaders, + SecurityMiddleware, + SanitizableRequest, + XssSanitizerMiddlewareOptions, +} from './cspMiddleware'; diff --git a/contracts/subscription/src/invoice_branding.rs b/contracts/subscription/src/invoice_branding.rs new file mode 100644 index 00000000..c3460095 --- /dev/null +++ b/contracts/subscription/src/invoice_branding.rs @@ -0,0 +1,24 @@ +use soroban_sdk::{contracttype, Env, String}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvoiceBranding { + pub logo_url: String, + pub primary_color: String, + pub font_family: String, + pub template_id: String, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrandingStorageKey { + InvoiceBranding(String), // tenant_id -> InvoiceBranding +} + +pub fn set_invoice_branding(env: &Env, tenant_id: String, branding: InvoiceBranding) { + env.storage().persistent().set(&BrandingStorageKey::InvoiceBranding(tenant_id), &branding); +} + +pub fn get_invoice_branding(env: &Env, tenant_id: String) -> Option { + env.storage().persistent().get(&BrandingStorageKey::InvoiceBranding(tenant_id)) +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index b0d064ae..e777fcdd 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -3,6 +3,7 @@ mod gas_optimization; mod gas_profiler; mod gas_storage; +mod invoice_branding; mod revenue; #[cfg(test)] mod test; @@ -1431,6 +1432,17 @@ impl SubTrackrSubscription { // ── Extended APIs (disabled by default) ── // + pub fn set_invoice_branding(env: Env, proxy: Address, storage: Address, tenant_id: String, branding: invoice_branding::InvoiceBranding) { + proxy.require_auth(); + let admin: Address = storage_instance_get(&env, &storage, StorageKey::Admin).expect("Admin not set"); + require_permission(&env, &storage, &admin, Permission::SetInvoiceContract); + invoice_branding::set_invoice_branding(&env, tenant_id, branding); + } + + pub fn get_invoice_branding(env: Env, tenant_id: String) -> Option { + invoice_branding::get_invoice_branding(&env, tenant_id) + } + // These APIs depend on additional modules/types that are still evolving. // Enable with `--features extended` in the `subtrackr-subscription` crate. #[cfg(feature = "extended")] diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 75139780..b09eb45a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -100,6 +100,23 @@ const TrialDetailsScreen = lazyScreen(() => import('../screens/TrialDetailsScree // Issue #547: GDPR const PrivacyCenterScreen = lazyScreen(() => import('../screens/PrivacyCenterScreen')); +const ChurnPredictionScreen = lazyScreen(() => import('../../app/screens/ChurnPredictionScreen')); + +const InvoiceCustomizationScreen = lazyScreen(() => + import('../../app/screens/InvoiceCustomizationScreen').then((m) => ({ + default: m.InvoiceCustomizationScreen, + })) +); +const InvoiceMarketplaceScreen = lazyScreen(() => + import('../../app/screens/InvoiceMarketplaceScreen').then((m) => ({ + default: m.InvoiceMarketplaceScreen, + })) +); +const InvoiceAnalyticsScreen = lazyScreen(() => + import('../../app/screens/InvoiceAnalyticsScreen').then((m) => ({ + default: m.InvoiceAnalyticsScreen, + })) +); const DataExportScreen = lazyScreen(() => import('../screens/DataExportScreen')); // Issue #548: Push notifications const NotificationPreferencesScreen = lazyScreen( @@ -236,6 +253,10 @@ const linking: LinkingOptions = { ApiKeyManagement: 'api-keys', DocumentationPortal: 'docs', IntegrationGuides: 'integration-guides', + ChurnPrediction: 'churn-analytics', + InvoiceCustomization: 'invoice/customization', + InvoiceMarketplace: 'invoice/marketplace', + InvoiceAnalytics: 'invoice/analytics', }, }, AddTab: 'add', @@ -392,6 +413,26 @@ const HomeStack = () => ( component={IntegrationGuidesScreen} options={{ title: 'Integrations', headerShown: true }} /> + + + + ): Invoice => { updatedAt: toValidDate(raw.updatedAt, createdAt), recipientEmail: raw.recipientEmail, notes: raw.notes, + branding: raw.branding, + templateId: raw.templateId, }; }; @@ -145,6 +149,12 @@ interface InvoiceState { taxRemittanceReports: TaxRemittanceReport[]; digitalGoodsClasses: Record; + templates: InvoiceTemplate[]; + + setInvoiceBranding: (branding: InvoiceBranding) => void; + setDefaultTemplate: (templateId: string) => void; + addTemplate: (template: InvoiceTemplate) => void; + generateInvoiceFromSubscription: ( data: InvoiceFormData, taxRateBps?: number, @@ -232,6 +242,29 @@ export const useInvoiceStore = create()( taxRemittanceReports: [], digitalGoodsClasses: {}, + templates: [ + { id: 'tpl-1', name: 'Standard', layout: 'standard' }, + { id: 'tpl-2', name: 'Modern', layout: 'modern' }, + ], + + setInvoiceBranding: (branding) => { + set((state) => ({ + config: { ...state.config, defaultBranding: branding }, + })); + }, + + setDefaultTemplate: (templateId) => { + set((state) => ({ + config: { ...state.config, defaultTemplateId: templateId }, + })); + }, + + addTemplate: (template) => { + set((state) => ({ + templates: [...state.templates, template], + })); + }, + generateInvoiceFromSubscription: async (data, taxRateBps, exchangeRate) => { set({ isLoading: true, error: null }); try { @@ -254,6 +287,9 @@ export const useInvoiceStore = create()( invoice.taxJurisdiction = data.taxJurisdiction; } + invoice.branding = state.config.defaultBranding; + invoice.templateId = state.config.defaultTemplateId || state.templates[0]?.id; + set((current) => ({ invoices: [...current.invoices, invoice], nextSequence: current.nextSequence + 1, diff --git a/src/types/invoice.ts b/src/types/invoice.ts index 855bcc3e..1d95637e 100644 --- a/src/types/invoice.ts +++ b/src/types/invoice.ts @@ -247,6 +247,18 @@ export interface InvoicePeriod { end: Date; } +export interface InvoiceBranding { + logoUrl?: string; + primaryColor?: string; + fontFamily?: string; +} + +export interface InvoiceTemplate { + id: string; + name: string; + layout: 'standard' | 'modern' | 'minimalist'; +} + export interface Invoice { id: string; invoiceNumber: string; @@ -272,6 +284,8 @@ export interface Invoice { isTaxExempt?: boolean; taxExemptionId?: string; reverseCharge?: boolean; + branding?: InvoiceBranding; + templateId?: string; } export interface InvoiceConfig { @@ -283,6 +297,8 @@ export interface InvoiceConfig { exchangeRateScale: number; paymentTermsDays: number; defaultTaxType: TaxType; + defaultBranding?: InvoiceBranding; + defaultTemplateId?: string; } export interface InvoiceTotals {