From 87d49251954f71ba40ae5c526945bcb09a0738e7 Mon Sep 17 00:00:00 2001 From: akintewe <85641756+akintewe@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:41:22 +0100 Subject: [PATCH 1/4] feat(#452): implement AI-powered dispute mediation system - Add ai-mediator.ts: NLP signal extraction, pattern-based classification, confidence scoring, auto-resolution at >95% confidence, human escalation - Add resolution-engine.ts: dispute lifecycle management, SLA enforcement, mediator decision recording, analytics dashboard data - Add disputes-ai.ts route: mediate, human-resolve, escalation-queue, analytics, and mediation-log endpoints --- backend/src/routes/disputes-ai.ts | 115 ++++++ backend/src/services/disputes/ai-mediator.ts | 352 ++++++++++++++++++ .../services/disputes/resolution-engine.ts | 277 ++++++++++++++ 3 files changed, 744 insertions(+) create mode 100644 backend/src/routes/disputes-ai.ts create mode 100644 backend/src/services/disputes/ai-mediator.ts create mode 100644 backend/src/services/disputes/resolution-engine.ts diff --git a/backend/src/routes/disputes-ai.ts b/backend/src/routes/disputes-ai.ts new file mode 100644 index 00000000..015fe35e --- /dev/null +++ b/backend/src/routes/disputes-ai.ts @@ -0,0 +1,115 @@ +/** + * AI Dispute Mediation Routes + * + * POST /disputes/:id/mediate — trigger AI analysis on a dispute + * POST /disputes/:id/human-resolve — mediator submits manual decision + * GET /disputes/escalation-queue — list disputes awaiting human review + * GET /disputes/analytics — resolution trend analytics + * GET /disputes/:id/mediation-log — fetch AI log for a dispute + */ + +import { Router, type Request, type Response } from 'express'; +import { + runAIMediation, + applyHumanResolution, + getDispute, + getEscalationQueue, + getDisputeAnalytics, + listDisputes, + createDispute, +} from '../services/disputes/resolution-engine.js'; +import { getMediationLog } from '../services/disputes/ai-mediator.js'; + +const router = Router(); + +// Trigger AI mediation on an existing dispute +router.post('/:id/mediate', async (req: Request, res: Response) => { + try { + const result = await runAIMediation(req.params.id); + res.json({ success: true, result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +// Human mediator overrides or confirms AI recommendation +router.post('/:id/human-resolve', async (req: Request, res: Response) => { + try { + const { decision, note, refundAmount, mediatorId } = req.body as { + decision: string; + note: string; + refundAmount?: number; + mediatorId?: string; + }; + + if (!decision || !note) { + res.status(400).json({ error: 'decision and note are required' }); + return; + } + + const resolution = await applyHumanResolution( + req.params.id, + mediatorId ?? 'system', + decision as Parameters[2], + note, + refundAmount + ); + + res.json({ success: true, resolution }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +// Escalation queue — disputes awaiting human review +router.get('/escalation-queue', (_req: Request, res: Response) => { + const queue = getEscalationQueue(); + res.json({ count: queue.length, disputes: queue }); +}); + +// Analytics dashboard +router.get('/analytics', (_req: Request, res: Response) => { + const analytics = getDisputeAnalytics(); + res.json(analytics); +}); + +// AI mediation log for a specific dispute +router.get('/:id/mediation-log', (req: Request, res: Response) => { + const log = getMediationLog(req.params.id); + if (!log) { + res.status(404).json({ error: 'No mediation log found for this dispute' }); + return; + } + res.json(log); +}); + +// Get dispute detail +router.get('/:id', (req: Request, res: Response) => { + const dispute = getDispute(req.params.id); + if (!dispute) { + res.status(404).json({ error: 'Dispute not found' }); + return; + } + res.json(dispute); +}); + +// List disputes, optionally filtered by status +router.get('/', (req: Request, res: Response) => { + const status = req.query.status as Parameters[0]; + res.json(listDisputes(status)); +}); + +// Create dispute (for testing / integration) +router.post('/', async (req: Request, res: Response) => { + try { + const dispute = await createDispute(req.body); + res.status(201).json(dispute); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +export default router; diff --git a/backend/src/services/disputes/ai-mediator.ts b/backend/src/services/disputes/ai-mediator.ts new file mode 100644 index 00000000..ee396c91 --- /dev/null +++ b/backend/src/services/disputes/ai-mediator.ts @@ -0,0 +1,352 @@ +/** + * AI-Powered Dispute Mediation Service + * + * Analyzes dispute evidence via NLP, classifies likely outcome, generates a + * confidence-scored resolution recommendation, and auto-resolves high-confidence + * cases while routing ambiguous ones to human mediators. + */ + +import { randomUUID } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DisputeCategory = + | 'service_not_delivered' + | 'partial_delivery' + | 'quality_issue' + | 'unauthorized_charge' + | 'duplicate_charge' + | 'other'; + +export type ResolutionRecommendation = + | 'full_refund' + | 'partial_refund' + | 'release_to_payee' + | 'needs_human_review'; + +export interface DisputeEvidenceInput { + id: string; + description: string; + fileUrl?: string; + fileName?: string; + submittedBy: string; + timestamp: string; +} + +export interface DisputeInput { + id: string; + description: string; + category: DisputeCategory; + amount: number; + currency: string; + evidence: DisputeEvidenceInput[]; + chatHistory?: string[]; + historicalOutcome?: ResolutionRecommendation; +} + +export interface MediationResult { + disputeId: string; + recommendation: ResolutionRecommendation; + confidenceScore: number; // 0–1 + reasoning: string; + suggestedRefundAmount?: number; + autoResolved: boolean; + escalatedToHuman: boolean; + aiSummary: string; + processedAt: string; +} + +export interface AIMediationLog { + id: string; + disputeId: string; + recommendation: ResolutionRecommendation; + confidenceScore: number; + reasoning: string; + autoResolved: boolean; + escalatedToHuman: boolean; + mediatorDecision?: ResolutionRecommendation; + mediatorNote?: string; + processedAt: string; + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// In-memory store for mediation logs (replace with Prisma in production) +// --------------------------------------------------------------------------- + +const mediationLogs = new Map(); + +// --------------------------------------------------------------------------- +// NLP helpers +// --------------------------------------------------------------------------- + +const FRAUD_SIGNALS = [ + 'unauthorized', + 'i did not make', + 'stolen', + 'fraud', + 'never ordered', + 'card stolen', + 'hacked', +]; + +const NON_DELIVERY_SIGNALS = [ + 'never received', + 'not delivered', + 'did not arrive', + 'no shipment', + 'tracking shows nothing', + 'not provided', +]; + +const QUALITY_SIGNALS = [ + 'broken', + 'defective', + 'not as described', + 'poor quality', + 'damaged', + 'wrong item', + 'incomplete', +]; + +const DUPLICATE_SIGNALS = [ + 'charged twice', + 'duplicate', + 'double charge', + 'billed twice', +]; + +function normalizeText(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9 ]/g, ' '); +} + +function countSignalMatches(text: string, signals: string[]): number { + return signals.reduce((acc, sig) => (text.includes(sig) ? acc + 1 : acc), 0); +} + +function extractKeySignals(description: string, evidence: DisputeEvidenceInput[]) { + const combinedText = normalizeText( + [description, ...evidence.map((e) => e.description)].join(' ') + ); + + return { + fraudMatches: countSignalMatches(combinedText, FRAUD_SIGNALS), + nonDeliveryMatches: countSignalMatches(combinedText, NON_DELIVERY_SIGNALS), + qualityMatches: countSignalMatches(combinedText, QUALITY_SIGNALS), + duplicateMatches: countSignalMatches(combinedText, DUPLICATE_SIGNALS), + evidenceCount: evidence.length, + hasFileEvidence: evidence.some((e) => !!e.fileUrl), + }; +} + +// --------------------------------------------------------------------------- +// Classification logic +// --------------------------------------------------------------------------- + +interface ClassificationResult { + recommendation: ResolutionRecommendation; + rawConfidence: number; + reasoning: string; + suggestedRefundFraction: number; +} + +function classifyDispute( + input: DisputeInput, + signals: ReturnType +): ClassificationResult { + const { fraudMatches, nonDeliveryMatches, qualityMatches, duplicateMatches, hasFileEvidence, evidenceCount } = signals; + + // Duplicate charge — nearly always refund + if (input.category === 'duplicate_charge' || duplicateMatches >= 1) { + return { + recommendation: 'full_refund', + rawConfidence: 0.93 + (evidenceCount > 0 ? 0.04 : 0), + reasoning: 'Dispute indicates a duplicate charge. High confidence full refund warranted.', + suggestedRefundFraction: 1, + }; + } + + // Unauthorized charge / fraud + if (input.category === 'unauthorized_charge' || fraudMatches >= 2) { + const conf = 0.88 + (hasFileEvidence ? 0.06 : 0); + return { + recommendation: 'full_refund', + rawConfidence: Math.min(conf, 0.97), + reasoning: 'Multiple fraud signals detected. Recommend full refund pending identity verification.', + suggestedRefundFraction: 1, + }; + } + + // Non-delivery + if (input.category === 'service_not_delivered' || nonDeliveryMatches >= 2) { + const conf = 0.82 + (hasFileEvidence ? 0.1 : 0); + return { + recommendation: conf >= 0.95 ? 'full_refund' : 'needs_human_review', + rawConfidence: conf, + reasoning: 'Non-delivery signals present. File evidence strengthens buyer claim.', + suggestedRefundFraction: 1, + }; + } + + // Quality issue — partial refund common + if (input.category === 'quality_issue' || qualityMatches >= 1) { + const conf = 0.72 + (hasFileEvidence ? 0.12 : 0) + (evidenceCount > 1 ? 0.05 : 0); + return { + recommendation: conf >= 0.95 ? 'partial_refund' : 'needs_human_review', + rawConfidence: conf, + reasoning: 'Quality issue reported. Partial refund likely; human review may be needed.', + suggestedRefundFraction: 0.5, + }; + } + + // Partial delivery + if (input.category === 'partial_delivery') { + return { + recommendation: 'partial_refund', + rawConfidence: 0.78, + reasoning: 'Partial delivery claim — proportional refund suggested.', + suggestedRefundFraction: 0.5, + }; + } + + // Release to payee (merchant wins) + if (evidenceCount === 0 && input.chatHistory && input.chatHistory.length === 0) { + return { + recommendation: 'release_to_payee', + rawConfidence: 0.65, + reasoning: 'No buyer evidence provided and no chat history. Tentatively favour payee.', + suggestedRefundFraction: 0, + }; + } + + // Default: needs human review + return { + recommendation: 'needs_human_review', + rawConfidence: 0.45, + reasoning: 'Insufficient signals for automated resolution. Escalating to human mediator.', + suggestedRefundFraction: 0, + }; +} + +// --------------------------------------------------------------------------- +// Historical pattern boost +// --------------------------------------------------------------------------- + +function applyHistoricalBoost( + result: ClassificationResult, + historicalOutcome?: ResolutionRecommendation +): ClassificationResult { + if (!historicalOutcome || historicalOutcome !== result.recommendation) return result; + + return { + ...result, + rawConfidence: Math.min(result.rawConfidence + 0.05, 0.99), + reasoning: result.reasoning + ' Historical pattern confirms this resolution type.', + }; +} + +// --------------------------------------------------------------------------- +// Summary generation +// --------------------------------------------------------------------------- + +function generateSummary(input: DisputeInput, result: ClassificationResult): string { + const lines: string[] = [ + `Dispute ID: ${input.id}`, + `Category: ${input.category}`, + `Amount: ${input.currency} ${input.amount.toFixed(2)}`, + `Evidence items: ${input.evidence.length}`, + `Recommendation: ${result.recommendation}`, + `Confidence: ${(result.rawConfidence * 100).toFixed(1)}%`, + `Reasoning: ${result.reasoning}`, + ]; + + if (result.recommendation === 'partial_refund') { + lines.push( + `Suggested refund: ${input.currency} ${(input.amount * result.suggestedRefundFraction).toFixed(2)}` + ); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +const AUTO_RESOLVE_THRESHOLD = 0.95; + +export async function analyzeDispute(input: DisputeInput): Promise { + const signals = extractKeySignals(input.description, input.evidence); + let classification = classifyDispute(input, signals); + classification = applyHistoricalBoost(classification, input.historicalOutcome); + + const autoResolved = + classification.rawConfidence >= AUTO_RESOLVE_THRESHOLD && + classification.recommendation !== 'needs_human_review'; + + const escalatedToHuman = !autoResolved; + + const suggestedRefundAmount = + classification.recommendation === 'full_refund' + ? input.amount + : classification.recommendation === 'partial_refund' + ? input.amount * classification.suggestedRefundFraction + : undefined; + + const result: MediationResult = { + disputeId: input.id, + recommendation: classification.recommendation, + confidenceScore: classification.rawConfidence, + reasoning: classification.reasoning, + suggestedRefundAmount, + autoResolved, + escalatedToHuman, + aiSummary: generateSummary(input, classification), + processedAt: new Date().toISOString(), + }; + + // Persist log + const log: AIMediationLog = { + id: randomUUID(), + disputeId: input.id, + recommendation: result.recommendation, + confidenceScore: result.confidenceScore, + reasoning: result.reasoning, + autoResolved, + escalatedToHuman, + processedAt: result.processedAt, + updatedAt: result.processedAt, + }; + mediationLogs.set(log.id, log); + + return result; +} + +export async function recordMediatorDecision( + disputeId: string, + decision: ResolutionRecommendation, + note: string +): Promise { + for (const log of mediationLogs.values()) { + if (log.disputeId === disputeId) { + log.mediatorDecision = decision; + log.mediatorNote = note; + log.updatedAt = new Date().toISOString(); + return log; + } + } + return null; +} + +export function getMediationLog(disputeId: string): AIMediationLog | undefined { + for (const log of mediationLogs.values()) { + if (log.disputeId === disputeId) return log; + } + return undefined; +} + +export function getAllMediationLogs(): AIMediationLog[] { + return Array.from(mediationLogs.values()); +} diff --git a/backend/src/services/disputes/resolution-engine.ts b/backend/src/services/disputes/resolution-engine.ts new file mode 100644 index 00000000..0cc6118f --- /dev/null +++ b/backend/src/services/disputes/resolution-engine.ts @@ -0,0 +1,277 @@ +/** + * Dispute Resolution Rules Engine + * + * Applies deterministic business rules on top of the AI mediator's recommendation + * to produce final resolution actions, enforce SLAs, and generate analytics. + */ + +import { randomUUID } from 'node:crypto'; +import { + analyzeDispute, + recordMediatorDecision, + getAllMediationLogs, + type DisputeInput, + type MediationResult, + type ResolutionRecommendation, + type AIMediationLog, +} from './ai-mediator.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DisputeStatus = + | 'awaiting_response' + | 'under_review' + | 'ai_pending' + | 'auto_resolved' + | 'escalated' + | 'resolved' + | 'dismissed'; + +export interface DisputeResolution { + id: string; + disputeId: string; + outcome: ResolutionRecommendation; + refundAmount?: number; + currency: string; + resolvedBy: 'ai' | 'human'; + mediatorId?: string; + note: string; + createdAt: string; +} + +export interface DisputeRecord { + id: string; + paymentId: string; + amount: number; + currency: string; + category: DisputeInput['category']; + description: string; + filedBy: string; + respondentId: string; + status: DisputeStatus; + evidence: DisputeInput['evidence']; + mediationResult?: MediationResult; + resolution?: DisputeResolution; + createdAt: string; + updatedAt: string; + slaDeadline: string; +} + +export interface DisputeAnalyticsSummary { + total: number; + autoResolved: number; + escalated: number; + humanResolved: number; + averageConfidenceScore: number; + resolutionBreakdown: Record; + slaBreachCount: number; + averageResolutionHours: number; +} + +// --------------------------------------------------------------------------- +// In-memory store +// --------------------------------------------------------------------------- + +const disputes = new Map(); +const resolutions = new Map(); + +const SLA_HOURS = 72; + +function slaDeadline(): string { + return new Date(Date.now() + SLA_HOURS * 3_600_000).toISOString(); +} + +// --------------------------------------------------------------------------- +// Engine API +// --------------------------------------------------------------------------- + +export async function createDispute( + input: Omit +): Promise { + const now = new Date().toISOString(); + const record: DisputeRecord = { + ...input, + id: randomUUID(), + status: 'awaiting_response', + createdAt: now, + updatedAt: now, + slaDeadline: slaDeadline(), + }; + disputes.set(record.id, record); + return record; +} + +export async function runAIMediation(disputeId: string): Promise { + const dispute = disputes.get(disputeId); + if (!dispute) throw new Error(`Dispute ${disputeId} not found`); + + dispute.status = 'ai_pending'; + dispute.updatedAt = new Date().toISOString(); + + const aiInput: DisputeInput = { + id: dispute.id, + description: dispute.description, + category: dispute.category, + amount: dispute.amount, + currency: dispute.currency, + evidence: dispute.evidence, + }; + + const result = await analyzeDispute(aiInput); + dispute.mediationResult = result; + + if (result.autoResolved) { + const resolution = buildResolution(dispute, result, 'ai'); + resolutions.set(resolution.id, resolution); + dispute.resolution = resolution; + dispute.status = 'auto_resolved'; + } else { + dispute.status = 'escalated'; + } + + dispute.updatedAt = new Date().toISOString(); + return result; +} + +export async function applyHumanResolution( + disputeId: string, + mediatorId: string, + decision: ResolutionRecommendation, + note: string, + refundAmount?: number +): Promise { + const dispute = disputes.get(disputeId); + if (!dispute) throw new Error(`Dispute ${disputeId} not found`); + + await recordMediatorDecision(disputeId, decision, note); + + const resolution: DisputeResolution = { + id: randomUUID(), + disputeId, + outcome: decision, + refundAmount, + currency: dispute.currency, + resolvedBy: 'human', + mediatorId, + note, + createdAt: new Date().toISOString(), + }; + + resolutions.set(resolution.id, resolution); + dispute.resolution = resolution; + dispute.status = 'resolved'; + dispute.updatedAt = new Date().toISOString(); + + return resolution; +} + +export function getDispute(id: string): DisputeRecord | undefined { + return disputes.get(id); +} + +export function listDisputes(status?: DisputeStatus): DisputeRecord[] { + const all = Array.from(disputes.values()); + return status ? all.filter((d) => d.status === status) : all; +} + +export function getEscalationQueue(): DisputeRecord[] { + return listDisputes('escalated'); +} + +// --------------------------------------------------------------------------- +// SLA enforcement +// --------------------------------------------------------------------------- + +export function checkSLABreaches(): DisputeRecord[] { + const now = new Date(); + const breaches: DisputeRecord[] = []; + + for (const dispute of disputes.values()) { + if ( + ['awaiting_response', 'under_review', 'ai_pending', 'escalated'].includes(dispute.status) && + new Date(dispute.slaDeadline) < now + ) { + breaches.push(dispute); + } + } + + return breaches; +} + +// --------------------------------------------------------------------------- +// Analytics +// --------------------------------------------------------------------------- + +export function getDisputeAnalytics(): DisputeAnalyticsSummary { + const logs: AIMediationLog[] = getAllMediationLogs(); + const allDisputes = Array.from(disputes.values()); + + const resolutionBreakdown: Record = { + full_refund: 0, + partial_refund: 0, + release_to_payee: 0, + needs_human_review: 0, + }; + + let totalConfidence = 0; + let autoResolved = 0; + let escalated = 0; + let humanResolved = 0; + let totalResolutionMs = 0; + let resolvedCount = 0; + + for (const log of logs) { + resolutionBreakdown[log.recommendation] = (resolutionBreakdown[log.recommendation] ?? 0) + 1; + totalConfidence += log.confidenceScore; + if (log.autoResolved) autoResolved++; + if (log.escalatedToHuman) escalated++; + if (log.mediatorDecision) humanResolved++; + } + + for (const d of allDisputes) { + if (d.resolution) { + const ms = + new Date(d.resolution.createdAt).getTime() - new Date(d.createdAt).getTime(); + totalResolutionMs += ms; + resolvedCount++; + } + } + + const slaBreachCount = checkSLABreaches().length; + const avgHours = + resolvedCount > 0 ? totalResolutionMs / resolvedCount / 3_600_000 : 0; + + return { + total: allDisputes.length, + autoResolved, + escalated, + humanResolved, + averageConfidenceScore: logs.length > 0 ? totalConfidence / logs.length : 0, + resolutionBreakdown, + slaBreachCount, + averageResolutionHours: avgHours, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildResolution( + dispute: DisputeRecord, + result: MediationResult, + resolvedBy: 'ai' | 'human' +): DisputeResolution { + return { + id: randomUUID(), + disputeId: dispute.id, + outcome: result.recommendation, + refundAmount: result.suggestedRefundAmount, + currency: dispute.currency, + resolvedBy, + note: result.reasoning, + createdAt: new Date().toISOString(), + }; +} From d6f60f4ee723652684cf78a293f3ece57490add6 Mon Sep 17 00:00:00 2001 From: akintewe <85641756+akintewe@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:43:44 +0100 Subject: [PATCH 2/4] feat(#453): implement webhook DLQ and enhanced delivery manager - Add delivery-manager.ts: configurable retry policies per endpoint, delivery logs with latency/status/payload preview, health dashboard with 99% success rate alerting, CSV export of delivery logs - Add dead-letter-queue.ts: permanent failure categorization, single/batch/ all replay from DLQ, purge operations, DLQ stats - Add webhooks-dlq.ts route: health, delivery-logs, DLQ CRUD, replay, endpoint retry policy management, CSV export endpoint --- backend/src/routes/webhooks-dlq.ts | 187 +++++++++ .../services/webhooks/dead-letter-queue.ts | 234 +++++++++++ .../src/services/webhooks/delivery-manager.ts | 376 ++++++++++++++++++ 3 files changed, 797 insertions(+) create mode 100644 backend/src/routes/webhooks-dlq.ts create mode 100644 backend/src/services/webhooks/dead-letter-queue.ts create mode 100644 backend/src/services/webhooks/delivery-manager.ts diff --git a/backend/src/routes/webhooks-dlq.ts b/backend/src/routes/webhooks-dlq.ts new file mode 100644 index 00000000..f90a63ca --- /dev/null +++ b/backend/src/routes/webhooks-dlq.ts @@ -0,0 +1,187 @@ +/** + * Webhook DLQ & Delivery Health Routes + * + * GET /webhooks/health — delivery health dashboard + * GET /webhooks/delivery-logs — paginated delivery log list + * GET /webhooks/delivery-logs/export.csv — CSV export + * GET /webhooks/dlq — list dead-letter queue entries + * GET /webhooks/dlq/stats — DLQ statistics + * POST /webhooks/dlq/:id/replay — replay single DLQ entry + * POST /webhooks/dlq/replay-batch — replay multiple entries + * POST /webhooks/dlq/replay-all — replay all entries (merchant-scoped) + * DELETE /webhooks/dlq/:id — purge single DLQ entry + * PUT /webhooks/endpoints/:id/retry-policy — update endpoint retry policy + */ + +import { Router, type Request, type Response } from 'express'; +import { + getDeliveryHealth, + listDeliveryLogs, + exportDeliveryLogsCSV, + updateEndpointRetryPolicy, + listEndpoints, + registerEndpoint, + DEFAULT_RETRY_POLICY, +} from '../services/webhooks/delivery-manager.js'; +import { + listDLQ, + getDLQEntry, + getDLQStats, + replaySingle, + replayBatch, + replayAll, + purgeDLQEntry, + syncDLQFromDeliveryLogs, +} from '../services/webhooks/dead-letter-queue.js'; + +const router = Router(); + +// --------------------------------------------------------------------------- +// Delivery health dashboard +// --------------------------------------------------------------------------- + +router.get('/health', (req: Request, res: Response) => { + const merchantId = req.query.merchantId as string | undefined; + const health = getDeliveryHealth(merchantId); + + if (health.alertTriggered) { + res.setHeader('X-Webhook-Alert', 'success-rate-below-99'); + } + + res.json(health); +}); + +// --------------------------------------------------------------------------- +// Delivery logs +// --------------------------------------------------------------------------- + +router.get('/delivery-logs', (req: Request, res: Response) => { + const { endpointId, merchantId, status } = req.query as Record; + const logs = listDeliveryLogs({ endpointId, merchantId, status: status as Parameters[0]['status'] }); + res.json({ count: logs.length, logs }); +}); + +router.get('/delivery-logs/export.csv', (req: Request, res: Response) => { + const merchantId = req.query.merchantId as string | undefined; + const csv = exportDeliveryLogsCSV(merchantId); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename="webhook-delivery-logs.csv"'); + res.send(csv); +}); + +// --------------------------------------------------------------------------- +// Dead-letter queue +// --------------------------------------------------------------------------- + +router.get('/dlq', (req: Request, res: Response) => { + syncDLQFromDeliveryLogs(); + const { merchantId, endpointId, failureCategory } = req.query as Record; + const entries = listDLQ({ + merchantId, + endpointId, + failureCategory: failureCategory as Parameters[0]['failureCategory'], + }); + res.json({ count: entries.length, entries }); +}); + +router.get('/dlq/stats', (req: Request, res: Response) => { + syncDLQFromDeliveryLogs(); + const merchantId = req.query.merchantId as string | undefined; + res.json(getDLQStats(merchantId)); +}); + +router.get('/dlq/:id', (req: Request, res: Response) => { + const entry = getDLQEntry(req.params.id); + if (!entry) { + res.status(404).json({ error: 'DLQ entry not found' }); + return; + } + res.json(entry); +}); + +// Replay single +router.post('/dlq/:id/replay', async (req: Request, res: Response) => { + try { + const result = await replaySingle(req.params.id); + res.json({ success: true, result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +// Replay batch +router.post('/dlq/replay-batch', async (req: Request, res: Response) => { + const { ids } = req.body as { ids?: string[] }; + if (!Array.isArray(ids) || ids.length === 0) { + res.status(400).json({ error: 'ids array is required' }); + return; + } + + const results = await replayBatch(ids); + res.json({ replayed: results.length, results }); +}); + +// Replay all +router.post('/dlq/replay-all', async (req: Request, res: Response) => { + const merchantId = (req.query.merchantId ?? req.body?.merchantId) as string | undefined; + const results = await replayAll(merchantId); + res.json({ replayed: results.length, results }); +}); + +// Purge +router.delete('/dlq/:id', (req: Request, res: Response) => { + try { + purgeDLQEntry(req.params.id); + res.json({ success: true, purged: req.params.id }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +// --------------------------------------------------------------------------- +// Endpoint management +// --------------------------------------------------------------------------- + +router.get('/endpoints', (req: Request, res: Response) => { + const merchantId = req.query.merchantId as string | undefined; + res.json(listEndpoints(merchantId)); +}); + +router.post('/endpoints', (req: Request, res: Response) => { + const { merchantId, url, secret, enabled, retryPolicy } = req.body as { + merchantId: string; + url: string; + secret: string; + enabled?: boolean; + retryPolicy?: Partial; + }; + + if (!merchantId || !url || !secret) { + res.status(400).json({ error: 'merchantId, url, and secret are required' }); + return; + } + + const endpoint = registerEndpoint({ + merchantId, + url, + secret, + enabled: enabled ?? true, + retryPolicy: { ...DEFAULT_RETRY_POLICY, ...(retryPolicy ?? {}) }, + }); + + res.status(201).json(endpoint); +}); + +router.put('/endpoints/:id/retry-policy', (req: Request, res: Response) => { + try { + const updated = updateEndpointRetryPolicy(req.params.id, req.body); + res.json(updated); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +export default router; diff --git a/backend/src/services/webhooks/dead-letter-queue.ts b/backend/src/services/webhooks/dead-letter-queue.ts new file mode 100644 index 00000000..b120f78a --- /dev/null +++ b/backend/src/services/webhooks/dead-letter-queue.ts @@ -0,0 +1,234 @@ +/** + * Webhook Dead-Letter Queue (DLQ) Management + * + * Handles permanent failure categorization, manual replay (single & batch), + * DLQ inspection, and purge operations. + */ + +import { randomUUID } from 'node:crypto'; +import { + deliveryLogs, + attemptDelivery, + listDeliveryLogs, + type DeliveryLog, + type FailureCategory, +} from './delivery-manager.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DeadLetterEntry { + id: string; + deliveryLogId: string; + endpointId: string; + merchantId: string; + eventId: string; + eventType: string; + payloadPreview: string; + failureCategory: FailureCategory; + lastError?: string; + originalAttempts: number; + enqueuedAt: string; + replayCount: number; + lastReplayAt?: string; + purgedAt?: string; +} + +export interface ReplayResult { + deadLetterEntryId: string; + deliveryLogId: string; + success: boolean; + status: string; + replayedAt: string; +} + +// --------------------------------------------------------------------------- +// In-memory DLQ store +// --------------------------------------------------------------------------- + +const dlqEntries = new Map(); + +// --------------------------------------------------------------------------- +// Auto-enqueue from delivery manager +// --------------------------------------------------------------------------- + +export function enqueueDeadLetter(log: DeliveryLog): DeadLetterEntry { + // Avoid duplicate entries for the same delivery log + for (const entry of dlqEntries.values()) { + if (entry.deliveryLogId === log.id && !entry.purgedAt) return entry; + } + + const entry: DeadLetterEntry = { + id: randomUUID(), + deliveryLogId: log.id, + endpointId: log.endpointId, + merchantId: log.merchantId, + eventId: log.eventId, + eventType: log.eventType, + payloadPreview: log.payloadPreview, + failureCategory: log.failureCategory ?? 'unknown', + lastError: log.lastError, + originalAttempts: log.attempt, + enqueuedAt: new Date().toISOString(), + replayCount: 0, + }; + + dlqEntries.set(entry.id, entry); + return entry; +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +export function listDLQ(filters: { + merchantId?: string; + endpointId?: string; + failureCategory?: FailureCategory; +} = {}): DeadLetterEntry[] { + return Array.from(dlqEntries.values()).filter((e) => { + if (e.purgedAt) return false; + if (filters.merchantId && e.merchantId !== filters.merchantId) return false; + if (filters.endpointId && e.endpointId !== filters.endpointId) return false; + if (filters.failureCategory && e.failureCategory !== filters.failureCategory) return false; + return true; + }); +} + +export function getDLQEntry(id: string): DeadLetterEntry | undefined { + return dlqEntries.get(id); +} + +// --------------------------------------------------------------------------- +// Replay +// --------------------------------------------------------------------------- + +async function replayOne(entry: DeadLetterEntry): Promise { + const now = new Date().toISOString(); + entry.replayCount += 1; + entry.lastReplayAt = now; + + // Reset the delivery log so it can be attempted again + const dlMap = deliveryLogs as Map; + const original = dlMap.get(entry.deliveryLogId); + if (original) { + original.status = 'pending'; + original.attempt = 0; + original.lastError = undefined; + original.failureCategory = undefined; + original.nextAttemptAt = now; + original.updatedAt = now; + dlMap.set(original.id, original); + } + + const result = await attemptDelivery(entry.deliveryLogId); + + return { + deadLetterEntryId: entry.id, + deliveryLogId: entry.deliveryLogId, + success: result.status === 'delivered', + status: result.status, + replayedAt: now, + }; +} + +export async function replaySingle(entryId: string): Promise { + const entry = dlqEntries.get(entryId); + if (!entry) throw new Error(`DLQ entry ${entryId} not found`); + if (entry.purgedAt) throw new Error(`DLQ entry ${entryId} has been purged`); + return replayOne(entry); +} + +export async function replayBatch( + entryIds: string[] +): Promise { + const results: ReplayResult[] = []; + for (const id of entryIds) { + const entry = dlqEntries.get(id); + if (!entry || entry.purgedAt) continue; + results.push(await replayOne(entry)); + } + return results; +} + +export async function replayAll(merchantId?: string): Promise { + const entries = listDLQ(merchantId ? { merchantId } : {}); + const results: ReplayResult[] = []; + for (const entry of entries) { + results.push(await replayOne(entry)); + } + return results; +} + +// --------------------------------------------------------------------------- +// Purge +// --------------------------------------------------------------------------- + +export function purgeDLQEntry(entryId: string): void { + const entry = dlqEntries.get(entryId); + if (!entry) throw new Error(`DLQ entry ${entryId} not found`); + entry.purgedAt = new Date().toISOString(); +} + +export function purgeByCategory( + category: FailureCategory, + merchantId?: string +): number { + const entries = listDLQ({ failureCategory: category, ...(merchantId ? { merchantId } : {}) }); + for (const e of entries) { + e.purgedAt = new Date().toISOString(); + } + return entries.length; +} + +// --------------------------------------------------------------------------- +// Sync DLQ from delivery logs (populate from existing dead_letter logs) +// --------------------------------------------------------------------------- + +export function syncDLQFromDeliveryLogs(): void { + const deadLogs = listDeliveryLogs({ status: 'dead_letter' }); + for (const log of deadLogs) { + enqueueDeadLetter(log); + } +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +export interface DLQStats { + total: number; + byCategory: Record; + totalReplayed: number; + pendingReplay: number; +} + +export function getDLQStats(merchantId?: string): DLQStats { + const entries = listDLQ(merchantId ? { merchantId } : {}); + + const byCategory: Record = { + invalid_endpoint: 0, + bad_signature: 0, + rate_limited: 0, + timeout: 0, + http_error: 0, + ssl_error: 0, + payload_too_large: 0, + unknown: 0, + }; + + let totalReplayed = 0; + + for (const e of entries) { + byCategory[e.failureCategory] = (byCategory[e.failureCategory] ?? 0) + 1; + if (e.replayCount > 0) totalReplayed++; + } + + return { + total: entries.length, + byCategory, + totalReplayed, + pendingReplay: entries.length - totalReplayed, + }; +} diff --git a/backend/src/services/webhooks/delivery-manager.ts b/backend/src/services/webhooks/delivery-manager.ts new file mode 100644 index 00000000..7d681407 --- /dev/null +++ b/backend/src/services/webhooks/delivery-manager.ts @@ -0,0 +1,376 @@ +/** + * Enhanced Webhook Delivery Manager + * + * Provides configurable retry policies per endpoint, delivery logs with + * status/latency/payload preview, success-rate alerting, and CSV export. + */ + +import { randomUUID } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DeliveryStatus = + | 'pending' + | 'processing' + | 'delivered' + | 'retrying' + | 'failed' + | 'dead_letter' + | 'expired'; + +export type FailureCategory = + | 'invalid_endpoint' + | 'bad_signature' + | 'rate_limited' + | 'timeout' + | 'http_error' + | 'ssl_error' + | 'payload_too_large' + | 'unknown'; + +export interface RetryPolicy { + maxRetries: number; + backoffMultiplier: number; // e.g. 2 → exponential doubling + initialDelayMs: number; + timeoutMs: number; + windowDays: number; // discard after this many days +} + +export const DEFAULT_RETRY_POLICY: RetryPolicy = { + maxRetries: 5, + backoffMultiplier: 2, + initialDelayMs: 1_000, + timeoutMs: 10_000, + windowDays: 7, +}; + +export interface WebhookEndpointConfig { + id: string; + merchantId: string; + url: string; + secret: string; + enabled: boolean; + retryPolicy: RetryPolicy; + createdAt: string; + updatedAt: string; +} + +export interface DeliveryLog { + id: string; + endpointId: string; + merchantId: string; + eventId: string; + eventType: string; + payloadPreview: string; // first 256 chars of JSON payload + status: DeliveryStatus; + attempt: number; + statusCode?: number; + responseLatencyMs?: number; + lastError?: string; + failureCategory?: FailureCategory; + nextAttemptAt?: string; + deliveredAt?: string; + expiredAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface DeliveryHealthSummary { + totalDeliveries: number; + deliveredCount: number; + failedCount: number; + deadLetterCount: number; + successRatePct: number; + averageLatencyMs: number; + failureBreakdown: Record; + alertTriggered: boolean; // true when success rate < 99% +} + +// --------------------------------------------------------------------------- +// In-memory stores (swap for Prisma/Redis in production) +// --------------------------------------------------------------------------- + +const endpoints = new Map(); +export const deliveryLogs = new Map(); + +// --------------------------------------------------------------------------- +// Endpoint management +// --------------------------------------------------------------------------- + +export function registerEndpoint( + config: Omit +): WebhookEndpointConfig { + const now = new Date().toISOString(); + const endpoint: WebhookEndpointConfig = { + ...config, + id: randomUUID(), + createdAt: now, + updatedAt: now, + }; + endpoints.set(endpoint.id, endpoint); + return endpoint; +} + +export function updateEndpointRetryPolicy( + endpointId: string, + policy: Partial +): WebhookEndpointConfig { + const ep = endpoints.get(endpointId); + if (!ep) throw new Error(`Endpoint ${endpointId} not found`); + ep.retryPolicy = { ...ep.retryPolicy, ...policy }; + ep.updatedAt = new Date().toISOString(); + return ep; +} + +export function getEndpoint(id: string): WebhookEndpointConfig | undefined { + return endpoints.get(id); +} + +export function listEndpoints(merchantId?: string): WebhookEndpointConfig[] { + const all = Array.from(endpoints.values()); + return merchantId ? all.filter((e) => e.merchantId === merchantId) : all; +} + +// --------------------------------------------------------------------------- +// Delivery scheduling +// --------------------------------------------------------------------------- + +export function scheduleDelivery( + endpointId: string, + merchantId: string, + eventId: string, + eventType: string, + payload: Record +): DeliveryLog { + const endpoint = endpoints.get(endpointId); + if (!endpoint) throw new Error(`Endpoint ${endpointId} not found`); + + const payloadJson = JSON.stringify(payload); + const now = new Date().toISOString(); + + const log: DeliveryLog = { + id: randomUUID(), + endpointId, + merchantId, + eventId, + eventType, + payloadPreview: payloadJson.slice(0, 256), + status: 'pending', + attempt: 0, + createdAt: now, + updatedAt: now, + nextAttemptAt: now, + }; + + deliveryLogs.set(log.id, log); + return log; +} + +// --------------------------------------------------------------------------- +// Delivery simulation (HTTP dispatch with retry logic) +// --------------------------------------------------------------------------- + +function classifyError(err: Error | string, statusCode?: number): FailureCategory { + const msg = typeof err === 'string' ? err.toLowerCase() : err.message.toLowerCase(); + if (statusCode === 429) return 'rate_limited'; + if (statusCode && statusCode >= 400 && statusCode < 500) return 'invalid_endpoint'; + if (msg.includes('ssl') || msg.includes('certificate')) return 'ssl_error'; + if (msg.includes('timeout') || msg.includes('abort')) return 'timeout'; + if (msg.includes('payload') || msg.includes('size')) return 'payload_too_large'; + if (statusCode && statusCode >= 500) return 'http_error'; + return 'unknown'; +} + +function nextDelay(policy: RetryPolicy, attempt: number): number { + return policy.initialDelayMs * Math.pow(policy.backoffMultiplier, attempt); +} + +function isExpired(log: DeliveryLog, policy: RetryPolicy): boolean { + const createdMs = new Date(log.createdAt).getTime(); + return Date.now() - createdMs > policy.windowDays * 86_400_000; +} + +export async function attemptDelivery( + logId: string, + _fetchFn?: (url: string, opts: RequestInit) => Promise<{ status: number; text: () => Promise }> +): Promise { + const log = deliveryLogs.get(logId); + if (!log) throw new Error(`Delivery log ${logId} not found`); + + const endpoint = endpoints.get(log.endpointId); + if (!endpoint) throw new Error(`Endpoint ${log.endpointId} not found`); + + const policy = endpoint.retryPolicy; + + if (isExpired(log, policy)) { + log.status = 'expired'; + log.expiredAt = new Date().toISOString(); + log.updatedAt = log.expiredAt; + deliveryLogs.set(logId, log); + return log; + } + + log.status = 'processing'; + log.attempt += 1; + log.updatedAt = new Date().toISOString(); + + const startMs = Date.now(); + + try { + // Use injected fetch or global fetch + const fetcher = _fetchFn ?? (fetch as typeof _fetchFn); + if (!fetcher) throw new Error('No fetch implementation available'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), policy.timeoutMs); + + const response = await fetcher(endpoint.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Event-Id': log.eventId }, + body: log.payloadPreview, + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + + log.responseLatencyMs = Date.now() - startMs; + log.statusCode = response.status; + + if (response.status >= 200 && response.status < 300) { + log.status = 'delivered'; + log.deliveredAt = new Date().toISOString(); + log.nextAttemptAt = undefined; + } else { + handleRetryOrDead(log, policy, classifyError('', response.status), response.status); + } + } catch (err: unknown) { + log.responseLatencyMs = Date.now() - startMs; + const error = err instanceof Error ? err : new Error(String(err)); + log.lastError = error.message; + handleRetryOrDead(log, policy, classifyError(error)); + } + + log.updatedAt = new Date().toISOString(); + deliveryLogs.set(logId, log); + return log; +} + +function handleRetryOrDead( + log: DeliveryLog, + policy: RetryPolicy, + category: FailureCategory, + statusCode?: number +): void { + log.failureCategory = category; + if (statusCode) log.statusCode = statusCode; + + if (log.attempt >= policy.maxRetries) { + log.status = 'dead_letter'; + log.nextAttemptAt = undefined; + } else { + log.status = 'retrying'; + log.nextAttemptAt = new Date(Date.now() + nextDelay(policy, log.attempt)).toISOString(); + } +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +export function getDeliveryLog(id: string): DeliveryLog | undefined { + return deliveryLogs.get(id); +} + +export function listDeliveryLogs( + filters: { endpointId?: string; merchantId?: string; status?: DeliveryStatus } = {} +): DeliveryLog[] { + return Array.from(deliveryLogs.values()).filter((l) => { + if (filters.endpointId && l.endpointId !== filters.endpointId) return false; + if (filters.merchantId && l.merchantId !== filters.merchantId) return false; + if (filters.status && l.status !== filters.status) return false; + return true; + }); +} + +// --------------------------------------------------------------------------- +// Health dashboard +// --------------------------------------------------------------------------- + +export function getDeliveryHealth(merchantId?: string): DeliveryHealthSummary { + const logs = listDeliveryLogs(merchantId ? { merchantId } : {}); + const total = logs.length; + const delivered = logs.filter((l) => l.status === 'delivered').length; + const failed = logs.filter((l) => l.status === 'failed').length; + const deadLetter = logs.filter((l) => l.status === 'dead_letter').length; + const successRate = total > 0 ? (delivered / total) * 100 : 100; + + const latencies = logs.filter((l) => l.responseLatencyMs != null).map((l) => l.responseLatencyMs!); + const avgLatency = latencies.length > 0 ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0; + + const failureBreakdown: Record = { + invalid_endpoint: 0, + bad_signature: 0, + rate_limited: 0, + timeout: 0, + http_error: 0, + ssl_error: 0, + payload_too_large: 0, + unknown: 0, + }; + + for (const log of logs) { + if (log.failureCategory) { + failureBreakdown[log.failureCategory] = (failureBreakdown[log.failureCategory] ?? 0) + 1; + } + } + + return { + totalDeliveries: total, + deliveredCount: delivered, + failedCount: failed, + deadLetterCount: deadLetter, + successRatePct: successRate, + averageLatencyMs: avgLatency, + failureBreakdown, + alertTriggered: successRate < 99, + }; +} + +// --------------------------------------------------------------------------- +// CSV export +// --------------------------------------------------------------------------- + +export function exportDeliveryLogsCSV(merchantId?: string): string { + const logs = listDeliveryLogs(merchantId ? { merchantId } : {}); + const header = [ + 'id', + 'endpointId', + 'eventId', + 'eventType', + 'status', + 'attempt', + 'statusCode', + 'responseLatencyMs', + 'failureCategory', + 'deliveredAt', + 'createdAt', + ].join(','); + + const rows = logs.map((l) => + [ + l.id, + l.endpointId, + l.eventId, + l.eventType, + l.status, + l.attempt, + l.statusCode ?? '', + l.responseLatencyMs ?? '', + l.failureCategory ?? '', + l.deliveredAt ?? '', + l.createdAt, + ].join(',') + ); + + return [header, ...rows].join('\n'); +} From 6fb981108699130d1932819310f1413af79ec661 Mon Sep 17 00:00:00 2001 From: akintewe <85641756+akintewe@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:46:24 +0100 Subject: [PATCH 3/4] feat(#454): implement Gas Station Network for EVM gas sponsorship - Add GSNForwarder.sol: EIP-2771/EIP-712 meta-transaction forwarder with authorized relayer management and on-chain nonce tracking - Add SponsorshipPolicy.sol: on-chain budget deposits, per-wallet rate limiting, and relay-server billing integration - Add relay-server.ts: meta-tx validation, EIP-1559 fee estimation with configurable multiplier, Redis-compatible nonce store, budget check, fallback-to-user-gas when budget exhausted - Add budget-manager.ts: per-merchant budget CRUD, top-up, rate-limit enforcement, gas spend recording, billing summaries - Add gsn.ts route: relay, estimate, budget management endpoints --- backend/src/routes/gsn.ts | 180 ++++++++++++++ backend/src/services/gsn/budget-manager.ts | 259 +++++++++++++++++++++ backend/src/services/gsn/relay-server.ts | 255 ++++++++++++++++++++ contracts/evm/gsn/GSNForwarder.sol | 175 ++++++++++++++ contracts/evm/gsn/SponsorshipPolicy.sol | 144 ++++++++++++ 5 files changed, 1013 insertions(+) create mode 100644 backend/src/routes/gsn.ts create mode 100644 backend/src/services/gsn/budget-manager.ts create mode 100644 backend/src/services/gsn/relay-server.ts create mode 100644 contracts/evm/gsn/GSNForwarder.sol create mode 100644 contracts/evm/gsn/SponsorshipPolicy.sol diff --git a/backend/src/routes/gsn.ts b/backend/src/routes/gsn.ts new file mode 100644 index 00000000..e81d4cc4 --- /dev/null +++ b/backend/src/routes/gsn.ts @@ -0,0 +1,180 @@ +/** + * GSN (Gas Station Network) Routes + * + * POST /gsn/relay — submit a meta-transaction for sponsorship + * GET /gsn/relay/:id — get relay record status + * GET /gsn/relay — list relay records + * GET /gsn/estimate — EIP-1559 gas estimate + * POST /gsn/budgets — create/top-up sponsorship budget + * GET /gsn/budgets/:merchantId — get budget details + * GET /gsn/budgets/:merchantId/summary — billing summary + * PUT /gsn/budgets/:merchantId/policy — update rate limit / gas cap + * GET /gsn/budgets/:merchantId/txs — list sponsored transactions + */ + +import { Router, type Request, type Response } from 'express'; +import { + submitMetaTransaction, + estimateEIP1559Gas, + shouldFallbackToUserGas, + getRelayRecord, + listRelayRecords, + type MetaTransactionRequest, + type GasEstimationConfig, +} from '../services/gsn/relay-server.js'; +import { + createBudget, + topUpBudget, + getBudget, + listBudgets, + updateBudgetPolicy, + getBillingSummary, + listSponsorshipTxs, +} from '../services/gsn/budget-manager.js'; + +const router = Router(); + +// Default gas config — in production, fetch from on-chain GasPriceOracle +const DEFAULT_GAS_CONFIG: GasEstimationConfig = { + baseFeeWei: 20_000_000_000n, // 20 gwei + priorityFeeWei: 1_500_000_000n, // 1.5 gwei + multiplier: 1.2, +}; + +// --------------------------------------------------------------------------- +// Meta-transaction relay +// --------------------------------------------------------------------------- + +router.post('/relay', async (req: Request, res: Response) => { + const body = req.body as MetaTransactionRequest & { ethUsdPrice?: number }; + + if (!body.from || !body.to || !body.signature || !body.merchantId) { + res.status(400).json({ error: 'from, to, signature, and merchantId are required' }); + return; + } + + // Check if we should fall back to user-pays-gas + if (shouldFallbackToUserGas(body.merchantId)) { + res.status(402).json({ + error: 'sponsorship_budget_exhausted', + fallback: 'user_pays_gas', + message: 'Merchant sponsorship budget is exhausted. User must pay gas directly.', + }); + return; + } + + try { + const record = await submitMetaTransaction(body, DEFAULT_GAS_CONFIG, body.ethUsdPrice); + + const statusCode = + record.status === 'confirmed' ? 200 + : record.status === 'submitted' ? 202 + : 400; + + res.status(statusCode).json({ success: record.status === 'confirmed', record }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: message }); + } +}); + +router.get('/relay', (req: Request, res: Response) => { + const { merchantId, userWallet, status } = req.query as Record; + const records = listRelayRecords({ + merchantId, + userWallet, + status: status as Parameters[0]['status'], + }); + res.json({ count: records.length, records }); +}); + +router.get('/relay/:id', (req: Request, res: Response) => { + const record = getRelayRecord(req.params.id); + if (!record) { + res.status(404).json({ error: 'Relay record not found' }); + return; + } + res.json(record); +}); + +// --------------------------------------------------------------------------- +// Gas estimation +// --------------------------------------------------------------------------- + +router.get('/estimate', (req: Request, res: Response) => { + const gasUnits = parseInt(String(req.query.gasUnits ?? '100000'), 10); + const ethUsdPrice = req.query.ethUsdPrice ? parseFloat(String(req.query.ethUsdPrice)) : undefined; + + const estimate = estimateEIP1559Gas(gasUnits, DEFAULT_GAS_CONFIG, ethUsdPrice); + res.json(estimate); +}); + +// --------------------------------------------------------------------------- +// Budget management +// --------------------------------------------------------------------------- + +router.post('/budgets', (req: Request, res: Response) => { + const { merchantId, depositWei, gasCapPerTx, rateLimitPerDay, topUp } = req.body as { + merchantId: string; + depositWei: string; + gasCapPerTx?: number; + rateLimitPerDay?: number; + topUp?: boolean; + }; + + if (!merchantId || !depositWei) { + res.status(400).json({ error: 'merchantId and depositWei are required' }); + return; + } + + try { + const budget = topUp + ? topUpBudget(merchantId, depositWei) + : createBudget(merchantId, depositWei, gasCapPerTx ?? 200_000, rateLimitPerDay ?? 50); + + res.status(topUp ? 200 : 201).json(budget); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +router.get('/budgets', (_req: Request, res: Response) => { + res.json(listBudgets()); +}); + +router.get('/budgets/:merchantId', (req: Request, res: Response) => { + const budget = getBudget(req.params.merchantId); + if (!budget) { + res.status(404).json({ error: 'Budget not found' }); + return; + } + res.json(budget); +}); + +router.get('/budgets/:merchantId/summary', (req: Request, res: Response) => { + try { + const includeTxs = req.query.includeTxs === 'true'; + const summary = getBillingSummary(req.params.merchantId, includeTxs); + res.json(summary); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(404).json({ error: message }); + } +}); + +router.put('/budgets/:merchantId/policy', (req: Request, res: Response) => { + try { + const updated = updateBudgetPolicy(req.params.merchantId, req.body); + res.json(updated); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(400).json({ error: message }); + } +}); + +router.get('/budgets/:merchantId/txs', (req: Request, res: Response) => { + res.json(listSponsorshipTxs(req.params.merchantId)); +}); + +export default router; diff --git a/backend/src/services/gsn/budget-manager.ts b/backend/src/services/gsn/budget-manager.ts new file mode 100644 index 00000000..e327979b --- /dev/null +++ b/backend/src/services/gsn/budget-manager.ts @@ -0,0 +1,259 @@ +/** + * GSN Sponsorship Budget Manager + * + * Tracks per-merchant sponsorship budgets, enforces per-wallet rate limits, + * bills gas costs, and provides billing summaries for merchant invoicing. + */ + +import { randomUUID } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SponsorshipBudget { + id: string; + merchantId: string; + totalDepositedWei: string; // BigInt as string for JSON safety + spentWei: string; + availableWei: string; + gasCapPerTx: number; // Max gas units per transaction + rateLimitPerDay: number; // Max sponsored txs per wallet per day + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SponsorshipTx { + id: string; + merchantId: string; + userWallet: string; + gasUnits: number; + gasCostWei: string; + txHash?: string; + chainId?: number; + billedAt: string; +} + +export interface BudgetCheckResult { + allowed: boolean; + reason?: string; + remainingBudgetWei?: string; + walletTxCountToday?: number; +} + +// --------------------------------------------------------------------------- +// In-memory stores (swap for Prisma GasSponsorshipBudget / GasSponsorshipTx) +// --------------------------------------------------------------------------- + +const budgets = new Map(); +const sponsorshipTxs: SponsorshipTx[] = []; + +// Per-wallet daily usage: merchantId → wallet → { count, windowStart } +const walletDailyUsage = new Map>(); + +// --------------------------------------------------------------------------- +// Budget CRUD +// --------------------------------------------------------------------------- + +export function createBudget( + merchantId: string, + depositWei: string, + gasCapPerTx: number, + rateLimitPerDay: number +): SponsorshipBudget { + const now = new Date().toISOString(); + const budget: SponsorshipBudget = { + id: randomUUID(), + merchantId, + totalDepositedWei: depositWei, + spentWei: '0', + availableWei: depositWei, + gasCapPerTx, + rateLimitPerDay, + active: true, + createdAt: now, + updatedAt: now, + }; + budgets.set(merchantId, budget); + return budget; +} + +export function topUpBudget(merchantId: string, additionalWei: string): SponsorshipBudget { + const budget = budgets.get(merchantId); + if (!budget) throw new Error(`Budget for merchant ${merchantId} not found`); + + const total = BigInt(budget.totalDepositedWei) + BigInt(additionalWei); + const available = BigInt(budget.availableWei) + BigInt(additionalWei); + + budget.totalDepositedWei = total.toString(); + budget.availableWei = available.toString(); + budget.active = true; + budget.updatedAt = new Date().toISOString(); + return budget; +} + +export function getBudget(merchantId: string): SponsorshipBudget | undefined { + return budgets.get(merchantId); +} + +export function listBudgets(): SponsorshipBudget[] { + return Array.from(budgets.values()); +} + +export function updateBudgetPolicy( + merchantId: string, + updates: Partial> +): SponsorshipBudget { + const budget = budgets.get(merchantId); + if (!budget) throw new Error(`Budget for merchant ${merchantId} not found`); + Object.assign(budget, updates, { updatedAt: new Date().toISOString() }); + return budget; +} + +// --------------------------------------------------------------------------- +// Budget verification (pre-flight check) +// --------------------------------------------------------------------------- + +export function verifyBudget( + merchantId: string, + userWallet: string, + gasUnits: number +): BudgetCheckResult { + const budget = budgets.get(merchantId); + + if (!budget) { + return { allowed: false, reason: 'budget_not_found' }; + } + + if (!budget.active) { + return { allowed: false, reason: 'budget_exhausted' }; + } + + if (gasUnits > budget.gasCapPerTx) { + return { allowed: false, reason: `gas_cap_exceeded:cap=${budget.gasCapPerTx}` }; + } + + // Check rate limit + const usage = getOrCreateWalletUsage(merchantId, userWallet.toLowerCase()); + if (usage.count >= budget.rateLimitPerDay) { + return { + allowed: false, + reason: 'rate_limit_exceeded', + walletTxCountToday: usage.count, + }; + } + + return { + allowed: true, + remainingBudgetWei: budget.availableWei, + walletTxCountToday: usage.count, + }; +} + +// --------------------------------------------------------------------------- +// Billing +// --------------------------------------------------------------------------- + +export function recordGasSpend( + merchantId: string, + userWallet: string, + gasUnits: number, + gasCostWei: string, + txHash?: string, + chainId?: number +): SponsorshipTx { + const budget = budgets.get(merchantId); + if (!budget) throw new Error(`Budget for merchant ${merchantId} not found`); + + const spent = BigInt(budget.spentWei) + BigInt(gasCostWei); + const available = BigInt(budget.totalDepositedWei) - spent; + + budget.spentWei = spent.toString(); + budget.availableWei = available > 0n ? available.toString() : '0'; + if (available <= 0n) budget.active = false; + budget.updatedAt = new Date().toISOString(); + + // Increment wallet daily usage + const usage = getOrCreateWalletUsage(merchantId, userWallet.toLowerCase()); + usage.count++; + + const tx: SponsorshipTx = { + id: randomUUID(), + merchantId, + userWallet: userWallet.toLowerCase(), + gasUnits, + gasCostWei, + txHash, + chainId, + billedAt: new Date().toISOString(), + }; + sponsorshipTxs.push(tx); + return tx; +} + +// --------------------------------------------------------------------------- +// Billing summary +// --------------------------------------------------------------------------- + +export interface BillingSummary { + merchantId: string; + totalSpentWei: string; + totalTxCount: number; + availableWei: string; + utilizationPct: number; + periodTxs?: SponsorshipTx[]; +} + +export function getBillingSummary( + merchantId: string, + includeTxs = false +): BillingSummary { + const budget = budgets.get(merchantId); + if (!budget) throw new Error(`Budget for merchant ${merchantId} not found`); + + const txs = sponsorshipTxs.filter((t) => t.merchantId === merchantId); + const deposited = BigInt(budget.totalDepositedWei); + const spent = BigInt(budget.spentWei); + const utilizationPct = + deposited > 0n ? Number((spent * 100n) / deposited) : 0; + + return { + merchantId, + totalSpentWei: budget.spentWei, + totalTxCount: txs.length, + availableWei: budget.availableWei, + utilizationPct, + periodTxs: includeTxs ? txs : undefined, + }; +} + +export function listSponsorshipTxs(merchantId?: string): SponsorshipTx[] { + return merchantId + ? sponsorshipTxs.filter((t) => t.merchantId === merchantId) + : [...sponsorshipTxs]; +} + +// --------------------------------------------------------------------------- +// Wallet daily usage helpers +// --------------------------------------------------------------------------- + +function getOrCreateWalletUsage( + merchantId: string, + wallet: string +): { count: number; windowStart: number } { + if (!walletDailyUsage.has(merchantId)) { + walletDailyUsage.set(merchantId, new Map()); + } + const merchantMap = walletDailyUsage.get(merchantId)!; + + const now = Date.now(); + let entry = merchantMap.get(wallet); + + if (!entry || now - entry.windowStart > 86_400_000) { + entry = { count: 0, windowStart: now }; + merchantMap.set(wallet, entry); + } + + return entry; +} diff --git a/backend/src/services/gsn/relay-server.ts b/backend/src/services/gsn/relay-server.ts new file mode 100644 index 00000000..1183454c --- /dev/null +++ b/backend/src/services/gsn/relay-server.ts @@ -0,0 +1,255 @@ +/** + * GSN Meta-Transaction Relay Server + * + * Accepts EIP-2771 signed meta-transactions from users, validates signatures, + * checks budget/rate-limits via the budget manager, submits transactions to + * the chain, and handles EIP-1559 fee estimation with configurable multiplier. + */ + +import { randomUUID } from 'node:crypto'; +import { verifyBudget, recordGasSpend, getBudget } from './budget-manager.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MetaTransactionRequest { + from: string; // User wallet address (hex) + to: string; // Target contract address (hex) + value: string; // ETH value in wei (hex string) + gas: string; // Gas limit (hex string) + nonce: string; // User nonce (hex string) + deadline: number; // Unix timestamp + data: string; // Encoded calldata (hex) + signature: string; // EIP-712 signature (hex) + chainId: number; + merchantId: string; // Which budget to debit +} + +export type RelayStatus = + | 'pending' + | 'submitted' + | 'confirmed' + | 'failed' + | 'rejected_budget' + | 'rejected_signature' + | 'rejected_rate_limit'; + +export interface RelayRecord { + id: string; + merchantId: string; + userWallet: string; + chainId: number; + txHash?: string; + status: RelayStatus; + gasUsed?: number; + effectiveGasPrice?: string; + gasCostWei?: string; + errorMessage?: string; + submittedAt?: string; + confirmedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface GasEstimate { + baseFeePerGas: string; // wei hex + maxPriorityFeePerGas: string; + maxFeePerGas: string; + estimatedGasUnits: number; + estimatedCostWei: string; + estimatedCostUSD?: number; +} + +// --------------------------------------------------------------------------- +// In-memory nonce store (replace with Redis in production) +// --------------------------------------------------------------------------- + +const nonceStore = new Map>>(); + +function isNonceUsed(wallet: string, chainId: number, nonce: string): boolean { + const byChain = nonceStore.get(wallet.toLowerCase()); + if (!byChain) return false; + return byChain.get(chainId)?.has(nonce) ?? false; +} + +function markNonceUsed(wallet: string, chainId: number, nonce: string): void { + const lower = wallet.toLowerCase(); + if (!nonceStore.has(lower)) nonceStore.set(lower, new Map()); + const byChain = nonceStore.get(lower)!; + if (!byChain.has(chainId)) byChain.set(chainId, new Set()); + byChain.get(chainId)!.add(nonce); +} + +// --------------------------------------------------------------------------- +// Relay records store +// --------------------------------------------------------------------------- + +const relayRecords = new Map(); + +// --------------------------------------------------------------------------- +// EIP-1559 gas estimation +// --------------------------------------------------------------------------- + +export interface GasEstimationConfig { + baseFeeWei: bigint; + priorityFeeWei: bigint; + multiplier: number; // e.g. 1.2 for 20% buffer +} + +export function estimateEIP1559Gas( + estimatedUnits: number, + config: GasEstimationConfig, + ethUsdPrice?: number +): GasEstimate { + const { baseFeeWei, priorityFeeWei, multiplier } = config; + + const maxPriority = BigInt(Math.ceil(Number(priorityFeeWei) * multiplier)); + const maxFee = BigInt(Math.ceil(Number(baseFeeWei * 2n + priorityFeeWei) * multiplier)); + const effectiveGasPrice = baseFeeWei + maxPriority; + const costWei = effectiveGasPrice * BigInt(estimatedUnits); + + const costUSD = + ethUsdPrice != null + ? (Number(costWei) / 1e18) * ethUsdPrice + : undefined; + + return { + baseFeePerGas: '0x' + baseFeeWei.toString(16), + maxPriorityFeePerGas: '0x' + maxPriority.toString(16), + maxFeePerGas: '0x' + maxFee.toString(16), + estimatedGasUnits: estimatedUnits, + estimatedCostWei: costWei.toString(), + estimatedCostUSD: costUSD, + }; +} + +// --------------------------------------------------------------------------- +// Signature validation (simplified — production uses ethers.js / viem) +// --------------------------------------------------------------------------- + +function isValidHexSignature(sig: string): boolean { + return /^0x[0-9a-fA-F]{130}$/.test(sig); +} + +function isDeadlinePassed(deadline: number): boolean { + return Date.now() / 1000 > deadline; +} + +// --------------------------------------------------------------------------- +// Core relay logic +// --------------------------------------------------------------------------- + +export async function submitMetaTransaction( + req: MetaTransactionRequest, + gasConfig: GasEstimationConfig, + ethUsdPrice?: number +): Promise { + const now = new Date().toISOString(); + const record: RelayRecord = { + id: randomUUID(), + merchantId: req.merchantId, + userWallet: req.from.toLowerCase(), + chainId: req.chainId, + status: 'pending', + createdAt: now, + updatedAt: now, + }; + relayRecords.set(record.id, record); + + // Validate signature format + if (!isValidHexSignature(req.signature)) { + record.status = 'rejected_signature'; + record.errorMessage = 'Invalid EIP-712 signature format'; + record.updatedAt = new Date().toISOString(); + return record; + } + + // Deadline check + if (isDeadlinePassed(req.deadline)) { + record.status = 'rejected_signature'; + record.errorMessage = 'Meta-transaction deadline has passed'; + record.updatedAt = new Date().toISOString(); + return record; + } + + // Replay protection + if (isNonceUsed(req.from, req.chainId, req.nonce)) { + record.status = 'rejected_signature'; + record.errorMessage = 'Nonce already used (replay attempt)'; + record.updatedAt = new Date().toISOString(); + return record; + } + + // Estimate gas + const gasEstimate = estimateEIP1559Gas( + parseInt(req.gas, 16) || 100_000, + gasConfig, + ethUsdPrice + ); + + // Budget check + const budgetCheck = verifyBudget(req.merchantId, req.from, gasEstimate.estimatedGasUnits); + if (!budgetCheck.allowed) { + record.status = budgetCheck.reason === 'rate_limit_exceeded' + ? 'rejected_rate_limit' + : 'rejected_budget'; + record.errorMessage = budgetCheck.reason; + record.updatedAt = new Date().toISOString(); + return record; + } + + // Mark nonce used and submit + markNonceUsed(req.from, req.chainId, req.nonce); + + record.status = 'submitted'; + record.submittedAt = new Date().toISOString(); + record.effectiveGasPrice = gasEstimate.maxFeePerGas; + record.updatedAt = record.submittedAt; + + // Simulate on-chain submission result (replace with ethers.js contract call) + const mockTxHash = '0x' + randomUUID().replace(/-/g, '') + '0000'; + record.txHash = mockTxHash; + record.gasUsed = gasEstimate.estimatedGasUnits; + record.gasCostWei = gasEstimate.estimatedCostWei; + record.status = 'confirmed'; + record.confirmedAt = new Date().toISOString(); + record.updatedAt = record.confirmedAt; + + // Debit budget + recordGasSpend(req.merchantId, req.from, gasEstimate.estimatedGasUnits, gasEstimate.estimatedCostWei); + + relayRecords.set(record.id, record); + return record; +} + +// --------------------------------------------------------------------------- +// Fallback: user pays gas +// --------------------------------------------------------------------------- + +export function shouldFallbackToUserGas(merchantId: string): boolean { + const budget = getBudget(merchantId); + if (!budget) return true; + return !budget.active || budget.availableWei === '0'; +} + +// --------------------------------------------------------------------------- +// Query helpers +// --------------------------------------------------------------------------- + +export function getRelayRecord(id: string): RelayRecord | undefined { + return relayRecords.get(id); +} + +export function listRelayRecords(filters: { + merchantId?: string; + userWallet?: string; + status?: RelayStatus; +} = {}): RelayRecord[] { + return Array.from(relayRecords.values()).filter((r) => { + if (filters.merchantId && r.merchantId !== filters.merchantId) return false; + if (filters.userWallet && r.userWallet !== filters.userWallet.toLowerCase()) return false; + if (filters.status && r.status !== filters.status) return false; + return true; + }); +} diff --git a/contracts/evm/gsn/GSNForwarder.sol b/contracts/evm/gsn/GSNForwarder.sol new file mode 100644 index 00000000..c9fcf6b1 --- /dev/null +++ b/contracts/evm/gsn/GSNForwarder.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title GSNForwarder +/// @notice EIP-2771 trusted forwarder for the AgenticPay Gas Station Network. +/// Verifies EIP-712 meta-transaction signatures and relays calls to +/// target contracts, appending the original sender address to calldata. +/// Supports EIP-1559 fee parameters for accurate gas accounting. +contract GSNForwarder { + // ── Types ──────────────────────────────────────────────────────────────── + + struct MetaTransaction { + address from; // Original signer / user wallet + address to; // Target contract + uint256 value; // Native value to forward + uint256 gas; // Gas limit for inner call + uint256 nonce; // Per-sender replay protection nonce + uint48 deadline; // Expiry timestamp + bytes data; // Encoded function call + } + + // ── Storage ────────────────────────────────────────────────────────────── + + bytes32 private immutable DOMAIN_SEPARATOR; + + bytes32 private constant METATX_TYPEHASH = keccak256( + "MetaTransaction(address from,address to,uint256 value,uint256 gas," + "uint256 nonce,uint48 deadline,bytes data)" + ); + + mapping(address => uint256) public nonces; + + // Authorised relay servers + mapping(address => bool) public authorizedRelayers; + address public owner; + + // ── Events ─────────────────────────────────────────────────────────────── + + event MetaTxExecuted( + address indexed from, + address indexed to, + uint256 nonce, + bool success, + uint256 gasUsed + ); + + event RelayerUpdated(address indexed relayer, bool authorized); + + // ── Errors ─────────────────────────────────────────────────────────────── + + error Unauthorized(); + error DeadlinePassed(); + error InvalidSignature(); + error NonceAlreadyUsed(); + error InsufficientRelayGas(); + + // ── Constructor ────────────────────────────────────────────────────────── + + constructor() { + owner = msg.sender; + DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version," + "uint256 chainId,address verifyingContract)" + ), + keccak256(bytes("AgenticPayGSN")), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } + + // ── Modifiers ──────────────────────────────────────────────────────────── + + modifier onlyOwner() { + if (msg.sender != owner) revert Unauthorized(); + _; + } + + modifier onlyRelayer() { + if (!authorizedRelayers[msg.sender]) revert Unauthorized(); + _; + } + + // ── Relayer management ─────────────────────────────────────────────────── + + function setRelayer(address relayer, bool authorized) external onlyOwner { + authorizedRelayers[relayer] = authorized; + emit RelayerUpdated(relayer, authorized); + } + + // ── Core relay ─────────────────────────────────────────────────────────── + + /// @notice Execute a signed meta-transaction on behalf of `req.from`. + /// @param req The meta-transaction parameters. + /// @param signature EIP-712 signature from `req.from`. + function execute( + MetaTransaction calldata req, + bytes calldata signature + ) external onlyRelayer returns (bool success, bytes memory returnData) { + if (block.timestamp > req.deadline) revert DeadlinePassed(); + if (nonces[req.from] != req.nonce) revert NonceAlreadyUsed(); + + bytes32 digest = _hashTypedData(req); + address signer = _recover(digest, signature); + if (signer != req.from) revert InvalidSignature(); + + // Increment nonce before execution to prevent reentrancy replay + unchecked { nonces[req.from]++; } + + uint256 gasBefore = gasleft(); + + // ERC-2771: append original sender to calldata + (success, returnData) = req.to.call{value: req.value, gas: req.gas}( + abi.encodePacked(req.data, req.from) + ); + + uint256 gasUsed = gasBefore - gasleft(); + emit MetaTxExecuted(req.from, req.to, req.nonce, success, gasUsed); + } + + // ── Nonce helper ───────────────────────────────────────────────────────── + + function getNonce(address sender) external view returns (uint256) { + return nonces[sender]; + } + + // ── EIP-712 ────────────────────────────────────────────────────────────── + + function _hashTypedData(MetaTransaction calldata req) internal view returns (bytes32) { + return keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256( + abi.encode( + METATX_TYPEHASH, + req.from, + req.to, + req.value, + req.gas, + req.nonce, + req.deadline, + keccak256(req.data) + ) + ) + ) + ); + } + + function domainSeparator() external view returns (bytes32) { + return DOMAIN_SEPARATOR; + } + + // ── ECDSA ──────────────────────────────────────────────────────────────── + + function _recover(bytes32 digest, bytes calldata sig) internal pure returns (address) { + if (sig.length != 65) revert InvalidSignature(); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := calldataload(sig.offset) + s := calldataload(add(sig.offset, 32)) + v := byte(0, calldataload(add(sig.offset, 64))) + } + address recovered = ecrecover(digest, v, r, s); + if (recovered == address(0)) revert InvalidSignature(); + return recovered; + } + + receive() external payable {} +} diff --git a/contracts/evm/gsn/SponsorshipPolicy.sol b/contracts/evm/gsn/SponsorshipPolicy.sol new file mode 100644 index 00000000..003346aa --- /dev/null +++ b/contracts/evm/gsn/SponsorshipPolicy.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title SponsorshipPolicy +/// @notice On-chain sponsorship budget management per merchant/app. +/// Merchants deposit ETH budgets; the relay server debits gas costs. +/// Enforces per-wallet rate limits and per-tx gas caps. +contract SponsorshipPolicy { + // ── Storage ────────────────────────────────────────────────────────────── + + address public owner; + address public relayServer; // Backend relay server address + + struct Budget { + uint256 totalDeposited; // Lifetime ETH deposited + uint256 spent; // ETH spent on sponsorship so far + uint256 gasCapPerTx; // Max gas units sponsorable per tx + uint256 rateLimitPerDay; // Max sponsored txs per wallet per day + bool active; + } + + struct WalletUsage { + uint256 txCountToday; + uint256 dayStart; // timestamp of day window start + } + + // merchantId (bytes32) → Budget + mapping(bytes32 => Budget) public budgets; + + // merchantId → walletAddress → daily usage + mapping(bytes32 => mapping(address => WalletUsage)) public walletUsage; + + // ── Events ─────────────────────────────────────────────────────────────── + + event BudgetDeposited(bytes32 indexed merchantId, uint256 amount); + event BudgetWithdrawn(bytes32 indexed merchantId, uint256 amount); + event GasCostBilled(bytes32 indexed merchantId, address indexed wallet, uint256 gasCostWei); + event BudgetExhausted(bytes32 indexed merchantId); + + // ── Errors ─────────────────────────────────────────────────────────────── + + error NotOwner(); + error NotRelayServer(); + error BudgetInactive(); + error BudgetExhaustedErr(); + error RateLimitExceeded(); + error GasCapExceeded(uint256 requested, uint256 cap); + error InsufficientBudget(uint256 needed, uint256 available); + + // ── Constructor ────────────────────────────────────────────────────────── + + constructor(address _relayServer) { + owner = msg.sender; + relayServer = _relayServer; + } + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + modifier onlyRelay() { + if (msg.sender != relayServer) revert NotRelayServer(); + _; + } + + // ── Budget management ──────────────────────────────────────────────────── + + function depositBudget( + bytes32 merchantId, + uint256 gasCapPerTx, + uint256 rateLimitPerDay + ) external payable { + Budget storage b = budgets[merchantId]; + b.totalDeposited += msg.value; + b.gasCapPerTx = gasCapPerTx; + b.rateLimitPerDay = rateLimitPerDay; + b.active = true; + emit BudgetDeposited(merchantId, msg.value); + } + + function withdrawBudget(bytes32 merchantId, uint256 amount) external onlyOwner { + Budget storage b = budgets[merchantId]; + uint256 available = b.totalDeposited - b.spent; + require(available >= amount, "Insufficient"); + b.totalDeposited -= amount; + payable(owner).transfer(amount); + emit BudgetWithdrawn(merchantId, amount); + } + + // ── Sponsorship check & billing ────────────────────────────────────────── + + /// @notice Called by relay server to verify and bill a sponsorship. + /// @param merchantId Merchant identifier. + /// @param wallet User wallet being sponsored. + /// @param gasUnits Gas units used by the meta-tx. + /// @param gasPrice Effective gas price (wei). + function billSponsorship( + bytes32 merchantId, + address wallet, + uint256 gasUnits, + uint256 gasPrice + ) external onlyRelay { + Budget storage b = budgets[merchantId]; + if (!b.active) revert BudgetInactive(); + + // Gas cap check + if (gasUnits > b.gasCapPerTx) revert GasCapExceeded(gasUnits, b.gasCapPerTx); + + // Rate limit check + WalletUsage storage usage = walletUsage[merchantId][wallet]; + _refreshDayWindow(usage); + if (usage.txCountToday >= b.rateLimitPerDay) revert RateLimitExceeded(); + + uint256 cost = gasUnits * gasPrice; + uint256 available = b.totalDeposited - b.spent; + if (cost > available) { + b.active = false; + emit BudgetExhausted(merchantId); + revert InsufficientBudget(cost, available); + } + + b.spent += cost; + usage.txCountToday++; + + emit GasCostBilled(merchantId, wallet, cost); + } + + /// @notice Returns available balance for a merchant budget. + function availableBalance(bytes32 merchantId) external view returns (uint256) { + Budget storage b = budgets[merchantId]; + return b.totalDeposited - b.spent; + } + + // ── Internal helpers ───────────────────────────────────────────────────── + + function _refreshDayWindow(WalletUsage storage usage) internal { + uint256 oneDayAgo = block.timestamp - 86_400; + if (usage.dayStart < oneDayAgo) { + usage.txCountToday = 0; + usage.dayStart = block.timestamp; + } + } +} From 5c3e83a96750f68a48503f86f61780cf4621e04d Mon Sep 17 00:00:00 2001 From: akintewe <85641756+akintewe@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:50:28 +0100 Subject: [PATCH 4/4] feat(#455): implement real-time collaborative project config editing - Add collaboration.ts WS handler: OT-based operation sync, presence tracking, critical-section locks (apiKey, webhookUrl, signingSecret), offline op queue/replay, per-user edit history with rollback - Add collaboration.ts REST route: history, participants, locked fields - Add useCollaboration hook: Socket.IO client, operation send with 300ms debounce, offline queue flush on reconnect, lock acquire/release - Add PresenceIndicators: avatar stack with cursor field tooltip - Add CollaborativeField: input with remote cursor, lock state styles - Add EditHistoryPanel: per-user attributed history with rollback confirm - Add CollaborativeProjectConfig: full project config form wired to collab --- backend/src/routes/collaboration.ts | 34 ++ backend/src/websocket/collaboration.ts | 334 ++++++++++++++++++ .../collaboration/CollaborativeField.tsx | 166 +++++++++ .../CollaborativeProjectConfig.tsx | 267 ++++++++++++++ .../collaboration/EditHistoryPanel.tsx | 155 ++++++++ .../collaboration/PresenceIndicators.tsx | 93 +++++ .../collaboration/useCollaboration.ts | 247 +++++++++++++ 7 files changed, 1296 insertions(+) create mode 100644 backend/src/routes/collaboration.ts create mode 100644 backend/src/websocket/collaboration.ts create mode 100644 frontend/components/collaboration/CollaborativeField.tsx create mode 100644 frontend/components/collaboration/CollaborativeProjectConfig.tsx create mode 100644 frontend/components/collaboration/EditHistoryPanel.tsx create mode 100644 frontend/components/collaboration/PresenceIndicators.tsx create mode 100644 frontend/components/collaboration/useCollaboration.ts diff --git a/backend/src/routes/collaboration.ts b/backend/src/routes/collaboration.ts new file mode 100644 index 00000000..14fd3585 --- /dev/null +++ b/backend/src/routes/collaboration.ts @@ -0,0 +1,34 @@ +/** + * Collaboration REST Routes + * + * GET /collaboration/:projectId/history — paginated edit history + * GET /collaboration/:projectId/participants — active participants + * GET /collaboration/:projectId/locks — locked fields + */ + +import { Router, type Request, type Response } from 'express'; +import { + getEditHistory, + getSessionParticipants, + getLockedFields, +} from '../websocket/collaboration.js'; + +const router = Router(); + +router.get('/:projectId/history', (req: Request, res: Response) => { + const limit = parseInt(String(req.query.limit ?? '100'), 10); + const history = getEditHistory(req.params.projectId, limit); + res.json({ projectId: req.params.projectId, count: history.length, history }); +}); + +router.get('/:projectId/participants', (req: Request, res: Response) => { + const participants = getSessionParticipants(req.params.projectId); + res.json({ projectId: req.params.projectId, count: participants.length, participants }); +}); + +router.get('/:projectId/locks', (req: Request, res: Response) => { + const locks = getLockedFields(req.params.projectId); + res.json({ projectId: req.params.projectId, locks }); +}); + +export default router; diff --git a/backend/src/websocket/collaboration.ts b/backend/src/websocket/collaboration.ts new file mode 100644 index 00000000..ae8b2e8b --- /dev/null +++ b/backend/src/websocket/collaboration.ts @@ -0,0 +1,334 @@ +/** + * Real-Time Collaborative Editing WebSocket Handler + * + * Handles operational transform (OT)-based synchronization for project + * configuration edits. Supports presence indicators, conflict resolution, + * edit history with per-user attribution, and critical section locking. + * + * Uses Redis pub/sub (stubbed) to broadcast across multiple server instances. + */ + +import { randomUUID } from 'node:crypto'; +import type { Server as SocketIOServer, Socket } from 'socket.io'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface EditOperation { + id: string; + projectId: string; + userId: string; + path: string[]; // JSON path to the field being edited, e.g. ["webhookUrl"] + type: 'set' | 'delete' | 'append' | 'increment'; + value?: unknown; + previousValue?: unknown; + timestamp: string; + version: number; // Monotonic version for OT ordering +} + +export interface PresenceState { + userId: string; + displayName: string; + avatarUrl?: string; + cursorField?: string; // Which field the user is currently editing + lastSeenAt: string; + connectionId: string; +} + +export interface ProjectEditHistory { + id: string; + projectId: string; + operations: EditOperation[]; + createdAt: string; +} + +export interface CollabSession { + projectId: string; + participants: Map; + version: number; + pendingOps: EditOperation[]; + lockedFields: Map; // field path → userId holding lock +} + +// --------------------------------------------------------------------------- +// In-memory session store (replace with Redis in production) +// --------------------------------------------------------------------------- + +const sessions = new Map(); +const editHistory = new Map(); // projectId → ops + +function getOrCreateSession(projectId: string): CollabSession { + if (!sessions.has(projectId)) { + sessions.set(projectId, { + projectId, + participants: new Map(), + version: 0, + pendingOps: [], + lockedFields: new Map(), + }); + } + return sessions.get(projectId)!; +} + +// --------------------------------------------------------------------------- +// Operational Transform helpers +// --------------------------------------------------------------------------- + +function applyOperation( + session: CollabSession, + op: EditOperation +): { applied: boolean; transformed?: EditOperation } { + // Simple last-write-wins OT for scalar fields + // Full OT (e.g. Yjs) would be used in production + if (op.version < session.version) { + // Stale op — transform by bumping version, keep value + const transformed: EditOperation = { ...op, version: session.version + 1 }; + session.version++; + storeHistory(session.projectId, transformed); + return { applied: true, transformed }; + } + + session.version++; + const withVersion: EditOperation = { ...op, version: session.version }; + storeHistory(session.projectId, withVersion); + return { applied: true, transformed: withVersion }; +} + +function storeHistory(projectId: string, op: EditOperation): void { + if (!editHistory.has(projectId)) editHistory.set(projectId, []); + editHistory.get(projectId)!.push(op); +} + +// --------------------------------------------------------------------------- +// Lock service +// --------------------------------------------------------------------------- + +const CRITICAL_FIELDS = new Set(['apiKey', 'webhookUrl', 'signingSecret', 'stripeKey']); + +function isCriticalField(fieldPath: string[]): boolean { + return fieldPath.some((seg) => CRITICAL_FIELDS.has(seg)); +} + +function acquireLock(session: CollabSession, fieldPath: string, userId: string): boolean { + const existing = session.lockedFields.get(fieldPath); + if (existing && existing !== userId) return false; + session.lockedFields.set(fieldPath, userId); + return true; +} + +function releaseLock(session: CollabSession, fieldPath: string, userId: string): void { + if (session.lockedFields.get(fieldPath) === userId) { + session.lockedFields.delete(fieldPath); + } +} + +// --------------------------------------------------------------------------- +// Socket.IO room management +// --------------------------------------------------------------------------- + +function projectRoom(projectId: string): string { + return `collab:project:${projectId}`; +} + +// --------------------------------------------------------------------------- +// Main collaboration handler +// --------------------------------------------------------------------------- + +export function registerCollaborationHandlers(io: SocketIOServer): void { + const collabNs = io.of('/collaboration'); + + collabNs.on('connection', (socket: Socket & { userId?: string; projectId?: string }) => { + const userId = (socket.handshake.auth.userId as string) || randomUUID(); + const displayName = (socket.handshake.auth.displayName as string) || `User ${userId.slice(0, 6)}`; + const avatarUrl = socket.handshake.auth.avatarUrl as string | undefined; + + socket.userId = userId; + + // ── join-project ──────────────────────────────────────────────────────── + + socket.on('join-project', (projectId: string) => { + socket.projectId = projectId; + void socket.join(projectRoom(projectId)); + + const session = getOrCreateSession(projectId); + const presence: PresenceState = { + userId, + displayName, + avatarUrl, + lastSeenAt: new Date().toISOString(), + connectionId: socket.id, + }; + session.participants.set(userId, presence); + + // Send current session state to the joining user + socket.emit('session-state', { + version: session.version, + participants: Array.from(session.participants.values()), + lockedFields: Object.fromEntries(session.lockedFields), + history: (editHistory.get(projectId) ?? []).slice(-50), + }); + + // Broadcast updated presence to others in the room + socket.to(projectRoom(projectId)).emit('presence-update', { + type: 'join', + user: presence, + participants: Array.from(session.participants.values()), + }); + }); + + // ── cursor-move ───────────────────────────────────────────────────────── + + socket.on('cursor-move', (data: { projectId: string; fieldPath: string }) => { + const session = sessions.get(data.projectId); + if (!session) return; + + const presence = session.participants.get(userId); + if (presence) { + presence.cursorField = data.fieldPath; + presence.lastSeenAt = new Date().toISOString(); + } + + socket.to(projectRoom(data.projectId)).emit('remote-cursor', { + userId, + fieldPath: data.fieldPath, + }); + }); + + // ── operation ──────────────────────────────────────────────────────────── + + socket.on('operation', (rawOp: Omit) => { + const projectId = socket.projectId; + if (!projectId) return; + + const session = getOrCreateSession(projectId); + + // Check critical section lock + const fieldKey = rawOp.path.join('.'); + if (isCriticalField(rawOp.path)) { + const lockHolder = session.lockedFields.get(fieldKey); + if (lockHolder && lockHolder !== userId) { + socket.emit('operation-rejected', { + reason: 'field_locked', + lockedBy: lockHolder, + field: fieldKey, + }); + return; + } + } + + const op: EditOperation = { + ...rawOp, + id: randomUUID(), + userId, + timestamp: new Date().toISOString(), + }; + + const { applied, transformed } = applyOperation(session, op); + + if (applied && transformed) { + // Acknowledge to sender + socket.emit('operation-ack', { operationId: op.id, version: transformed.version }); + // Broadcast to other participants + socket.to(projectRoom(projectId)).emit('remote-operation', transformed); + } + }); + + // ── acquire-lock ───────────────────────────────────────────────────────── + + socket.on('acquire-lock', (data: { projectId: string; fieldPath: string }) => { + const session = getOrCreateSession(data.projectId); + const granted = acquireLock(session, data.fieldPath, userId); + + socket.emit('lock-result', { fieldPath: data.fieldPath, granted, userId }); + + if (granted) { + socket.to(projectRoom(data.projectId)).emit('field-locked', { + fieldPath: data.fieldPath, + lockedBy: userId, + }); + } + }); + + // ── release-lock ───────────────────────────────────────────────────────── + + socket.on('release-lock', (data: { projectId: string; fieldPath: string }) => { + const session = sessions.get(data.projectId); + if (!session) return; + + releaseLock(session, data.fieldPath, userId); + collabNs.to(projectRoom(data.projectId)).emit('field-unlocked', { + fieldPath: data.fieldPath, + }); + }); + + // ── rollback ───────────────────────────────────────────────────────────── + + socket.on('rollback', (data: { projectId: string; toVersion: number }) => { + const history = editHistory.get(data.projectId) ?? []; + const snapshot = history.filter((op) => op.version <= data.toVersion); + socket.emit('rollback-snapshot', { version: data.toVersion, operations: snapshot }); + }); + + // ── offline-sync ───────────────────────────────────────────────────────── + + socket.on('offline-ops', (data: { projectId: string; ops: EditOperation[] }) => { + const session = getOrCreateSession(data.projectId); + const results: EditOperation[] = []; + + for (const op of data.ops) { + const { transformed } = applyOperation(session, { ...op, userId }); + if (transformed) results.push(transformed); + } + + socket.emit('offline-sync-complete', { applied: results.length, ops: results }); + socket.to(projectRoom(data.projectId)).emit('bulk-remote-operations', results); + }); + + // ── disconnect ──────────────────────────────────────────────────────────── + + socket.on('disconnect', () => { + const projectId = socket.projectId; + if (!projectId) return; + + const session = sessions.get(projectId); + if (!session) return; + + session.participants.delete(userId); + + // Release any locks held by this user + for (const [field, holder] of session.lockedFields) { + if (holder === userId) { + session.lockedFields.delete(field); + collabNs.to(projectRoom(projectId)).emit('field-unlocked', { fieldPath: field }); + } + } + + collabNs.to(projectRoom(projectId)).emit('presence-update', { + type: 'leave', + userId, + participants: Array.from(session.participants.values()), + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Query helpers (for REST endpoints) +// --------------------------------------------------------------------------- + +export function getEditHistory(projectId: string, limit = 100): EditOperation[] { + return (editHistory.get(projectId) ?? []).slice(-limit); +} + +export function getSessionParticipants(projectId: string): PresenceState[] { + const session = sessions.get(projectId); + if (!session) return []; + return Array.from(session.participants.values()); +} + +export function getLockedFields(projectId: string): Record { + const session = sessions.get(projectId); + if (!session) return {}; + return Object.fromEntries(session.lockedFields); +} diff --git a/frontend/components/collaboration/CollaborativeField.tsx b/frontend/components/collaboration/CollaborativeField.tsx new file mode 100644 index 00000000..40cf45aa --- /dev/null +++ b/frontend/components/collaboration/CollaborativeField.tsx @@ -0,0 +1,166 @@ +'use client'; + +import React, { useRef, useEffect, useCallback } from 'react'; +import type { UseCollaborationReturn } from './useCollaboration'; + +interface CollaborativeFieldProps { + label: string; + fieldPath: string[]; + value: string; + onChange: (value: string) => void; + collaboration: UseCollaborationReturn; + currentUserId: string; + isCritical?: boolean; + placeholder?: string; + type?: 'text' | 'url' | 'password'; + disabled?: boolean; + className?: string; +} + +export function CollaborativeField({ + label, + fieldPath, + value, + onChange, + collaboration, + currentUserId, + isCritical = false, + placeholder, + type = 'text', + disabled = false, + className = '', +}: CollaborativeFieldProps) { + const { lockedFields, participants, sendOperation, moveCursor, acquireLock, releaseLock } = + collaboration; + + const fieldKey = fieldPath.join('.'); + const lockHolder = lockedFields[fieldKey]; + const isLockedByMe = lockHolder === currentUserId; + const isLockedByOther = lockHolder && lockHolder !== currentUserId; + + const lockHolderUser = isLockedByOther + ? participants.find((p) => p.userId === lockHolder) + : null; + + const remoteEditor = participants.find( + (p) => p.userId !== currentUserId && p.cursorField === fieldKey + ); + + const prevValueRef = useRef(value); + const debounceRef = useRef | null>(null); + + const handleFocus = useCallback(async () => { + moveCursor(fieldKey); + + if (isCritical) { + const granted = await acquireLock(fieldKey); + if (!granted) return; // blocked — input will be disabled via isLockedByOther + } + }, [fieldKey, isCritical, moveCursor, acquireLock]); + + const handleBlur = useCallback(() => { + if (isCritical && isLockedByMe) { + releaseLock(fieldKey); + } + }, [fieldKey, isCritical, isLockedByMe, releaseLock]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + + // Debounce op emission to avoid spamming on every keystroke + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + sendOperation({ + path: fieldPath, + type: 'set', + value: newValue, + previousValue: prevValueRef.current, + version: collaboration.currentVersion, + }); + prevValueRef.current = newValue; + }, 300); + }, + [onChange, sendOperation, fieldPath, collaboration.currentVersion] + ); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const isInputDisabled = disabled || (isCritical && !!isLockedByOther); + + return ( +
+ + +
+ + + {/* Lock indicator */} + {isLockedByOther && ( +
+ + + + + {lockHolderUser?.displayName ?? 'Another user'} + +
+ )} + + {/* Remote cursor indicator */} + {!isLockedByOther && remoteEditor && ( +
+ + {remoteEditor.displayName} +
+ )} +
+ + {/* Status messages */} + {isLockedByOther && ( +

+ Locked by {lockHolderUser?.displayName ?? 'another user'} — read-only +

+ )} + {isLockedByMe && ( +

You hold the edit lock on this field.

+ )} +
+ ); +} diff --git a/frontend/components/collaboration/CollaborativeProjectConfig.tsx b/frontend/components/collaboration/CollaborativeProjectConfig.tsx new file mode 100644 index 00000000..7843e91e --- /dev/null +++ b/frontend/components/collaboration/CollaborativeProjectConfig.tsx @@ -0,0 +1,267 @@ +'use client'; + +import React, { useCallback, useState } from 'react'; +import { useCollaboration } from './useCollaboration'; +import { PresenceIndicators } from './PresenceIndicators'; +import { CollaborativeField } from './CollaborativeField'; +import { EditHistoryPanel } from './EditHistoryPanel'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ProjectConfig { + name: string; + description: string; + webhookUrl: string; + apiKey: string; + signingSecret: string; + successRedirectUrl: string; + cancelRedirectUrl: string; + currency: string; +} + +interface CollaborativeProjectConfigProps { + projectId: string; + initialConfig: ProjectConfig; + userId: string; + displayName: string; + avatarUrl?: string; + onSave?: (config: ProjectConfig) => Promise; + wsServerUrl?: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function CollaborativeProjectConfig({ + projectId, + initialConfig, + userId, + displayName, + avatarUrl, + onSave, + wsServerUrl = '', +}: CollaborativeProjectConfigProps) { + const [config, setConfig] = useState(initialConfig); + const [historyOpen, setHistoryOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(''); + + const collaboration = useCollaboration({ + projectId, + userId, + displayName, + avatarUrl, + serverUrl: wsServerUrl, + onRemoteOperation: useCallback( + (op) => { + if (op.type === 'set' && op.path.length === 1) { + const field = op.path[0] as keyof ProjectConfig; + setConfig((prev) => ({ ...prev, [field]: op.value as string })); + } + }, + [] + ), + }); + + const handleFieldChange = useCallback( + (field: keyof ProjectConfig) => (value: string) => { + setConfig((prev) => ({ ...prev, [field]: value })); + }, + [] + ); + + const handleSave = async () => { + if (!onSave) return; + setIsSaving(true); + try { + await onSave(config); + setSaveMessage('Saved successfully'); + setTimeout(() => setSaveMessage(''), 3000); + } catch { + setSaveMessage('Save failed — try again'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* Toolbar */} +
+
+
+ + + {collaboration.isConnected ? 'Live' : 'Connecting…'} + +
+ | + +
+ +
+ v{collaboration.currentVersion} + +
+
+ + {/* Fields */} +
+
+

+ General +

+
+ + +
+
+ +
+
+ +
+

+ Redirects +

+
+ + +
+
+ +
+

+ Security — Critical Fields +

+

+ These fields are locked during editing to prevent concurrent conflicts. +

+
+ + +
+
+ +
+
+
+ + {/* Save bar */} + {onSave && ( +
+ + {saveMessage && ( + + {saveMessage} + + )} +
+ )} + + {/* Edit history panel */} + setHistoryOpen(false)} + /> +
+ ); +} diff --git a/frontend/components/collaboration/EditHistoryPanel.tsx b/frontend/components/collaboration/EditHistoryPanel.tsx new file mode 100644 index 00000000..6cb7c1d3 --- /dev/null +++ b/frontend/components/collaboration/EditHistoryPanel.tsx @@ -0,0 +1,155 @@ +'use client'; + +import React, { useState } from 'react'; +import type { EditOperation } from './useCollaboration'; +import type { CollabUser } from './useCollaboration'; + +interface EditHistoryPanelProps { + history: EditOperation[]; + participants: CollabUser[]; + currentVersion: number; + onRollback: (version: number) => void; + isOpen: boolean; + onClose: () => void; +} + +function formatTimestamp(iso: string): string { + const date = new Date(iso); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function formatPath(path: string[]): string { + return path.join(' › '); +} + +export function EditHistoryPanel({ + history, + participants, + currentVersion, + onRollback, + isOpen, + onClose, +}: EditHistoryPanelProps) { + const [confirmRollback, setConfirmRollback] = useState(null); + + if (!isOpen) return null; + + const userMap = new Map(participants.map((p) => [p.userId, p])); + const reversed = [...history].reverse(); + + return ( +
+ {/* Header */} +
+

Edit History

+ +
+ +
+ Current version: v{currentVersion} + {' · '} + {history.length} operations +
+ + {/* Operations list */} +
+ {reversed.length === 0 ? ( +
+ No edits yet +
+ ) : ( +
    + {reversed.map((op) => { + const user = userMap.get(op.userId); + const isLatest = op.version === currentVersion; + + return ( +
  • +
    +
    +
    + + {user?.displayName ?? op.userId.slice(0, 8)} + + + {op.timestamp ? formatTimestamp(op.timestamp as string) : ''} + + {isLatest && ( + + latest + + )} +
    +

    + {formatPath(op.path)} + {' '} + {op.type} + {op.value != null && ( + + {' → '} + {JSON.stringify(op.value).slice(0, 30)} + + )} +

    +

    v{op.version}

    +
    + + {/* Rollback button */} + {!isLatest && ( + + )} +
    +
  • + ); + })} +
+ )} +
+ + {/* Rollback confirmation */} + {confirmRollback !== null && ( +
+

+ Roll back to v{confirmRollback}? This will revert all changes after this point. +

+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/components/collaboration/PresenceIndicators.tsx b/frontend/components/collaboration/PresenceIndicators.tsx new file mode 100644 index 00000000..596bcfde --- /dev/null +++ b/frontend/components/collaboration/PresenceIndicators.tsx @@ -0,0 +1,93 @@ +'use client'; + +import React from 'react'; +import type { CollabUser } from './useCollaboration'; + +interface PresenceIndicatorsProps { + participants: CollabUser[]; + currentUserId: string; + maxVisible?: number; +} + +const AVATAR_COLORS = [ + 'bg-blue-500', + 'bg-emerald-500', + 'bg-violet-500', + 'bg-amber-500', + 'bg-rose-500', + 'bg-cyan-500', +]; + +function colorForUser(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = (hash * 31 + userId.charCodeAt(i)) & 0xffffffff; + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +} + +export function PresenceIndicators({ + participants, + currentUserId, + maxVisible = 5, +}: PresenceIndicatorsProps) { + const others = participants.filter((p) => p.userId !== currentUserId); + const visible = others.slice(0, maxVisible); + const overflow = others.length - maxVisible; + + if (others.length === 0) { + return ( + + Only you are editing + + ); + } + + return ( +
+ + {others.length === 1 ? '1 other' : `${others.length} others`} editing + +
+ {visible.map((user) => ( +
+ {user.avatarUrl ? ( + {user.displayName} + ) : ( +
+ {user.displayName.slice(0, 2).toUpperCase()} +
+ )} + + {/* Active indicator dot */} + + + {/* Tooltip */} +
+ {user.displayName} + {user.cursorField && ( + — editing {user.cursorField} + )} +
+
+ ))} + + {overflow > 0 && ( +
+ +{overflow} +
+ )} +
+
+ ); +} diff --git a/frontend/components/collaboration/useCollaboration.ts b/frontend/components/collaboration/useCollaboration.ts new file mode 100644 index 00000000..9a7fa0c3 --- /dev/null +++ b/frontend/components/collaboration/useCollaboration.ts @@ -0,0 +1,247 @@ +/** + * useCollaboration hook + * + * Manages WebSocket connection to the collaboration namespace, applies + * incoming remote operations, tracks presence, and handles offline queuing. + */ + +'use client'; + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { io, type Socket } from 'socket.io-client'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CollabUser { + userId: string; + displayName: string; + avatarUrl?: string; + cursorField?: string; + lastSeenAt: string; + connectionId: string; +} + +export type OperationType = 'set' | 'delete' | 'append' | 'increment'; + +export interface EditOperation { + id?: string; + projectId: string; + path: string[]; + type: OperationType; + value?: unknown; + previousValue?: unknown; + version: number; +} + +export interface SessionState { + version: number; + participants: CollabUser[]; + lockedFields: Record; + history: EditOperation[]; +} + +export interface UseCollaborationOptions { + projectId: string; + userId: string; + displayName: string; + avatarUrl?: string; + serverUrl?: string; + onRemoteOperation?: (op: EditOperation) => void; + onPresenceChange?: (participants: CollabUser[]) => void; +} + +export interface UseCollaborationReturn { + isConnected: boolean; + participants: CollabUser[]; + lockedFields: Record; + currentVersion: number; + sendOperation: (op: Omit) => void; + moveCursor: (fieldPath: string) => void; + acquireLock: (fieldPath: string) => Promise; + releaseLock: (fieldPath: string) => void; + rollbackTo: (version: number) => void; + editHistory: EditOperation[]; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useCollaboration({ + projectId, + userId, + displayName, + avatarUrl, + serverUrl = '', + onRemoteOperation, + onPresenceChange, +}: UseCollaborationOptions): UseCollaborationReturn { + const socketRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [participants, setParticipants] = useState([]); + const [lockedFields, setLockedFields] = useState>({}); + const [currentVersion, setCurrentVersion] = useState(0); + const [editHistory, setEditHistory] = useState([]); + + // Offline queue — ops sent while disconnected + const offlineQueueRef = useRef([]); + const versionRef = useRef(0); + + // --------------------------------------------------------------------------- + // Connect + // --------------------------------------------------------------------------- + + useEffect(() => { + const socket = io(`${serverUrl}/collaboration`, { + auth: { userId, displayName, avatarUrl }, + transports: ['websocket'], + reconnectionAttempts: 10, + reconnectionDelay: 1000, + }); + + socketRef.current = socket; + + socket.on('connect', () => { + setIsConnected(true); + socket.emit('join-project', projectId); + + // Flush offline queue + if (offlineQueueRef.current.length > 0) { + socket.emit('offline-ops', { projectId, ops: offlineQueueRef.current }); + offlineQueueRef.current = []; + } + }); + + socket.on('disconnect', () => setIsConnected(false)); + + socket.on('session-state', (state: SessionState) => { + setParticipants(state.participants); + setLockedFields(state.lockedFields); + setCurrentVersion(state.version); + versionRef.current = state.version; + setEditHistory(state.history); + }); + + socket.on('presence-update', (data: { participants: CollabUser[] }) => { + setParticipants(data.participants); + onPresenceChange?.(data.participants); + }); + + socket.on('remote-operation', (op: EditOperation) => { + versionRef.current = op.version; + setCurrentVersion(op.version); + setEditHistory((prev) => [...prev, op]); + onRemoteOperation?.(op); + }); + + socket.on('bulk-remote-operations', (ops: EditOperation[]) => { + if (ops.length > 0) { + const latest = ops[ops.length - 1]; + versionRef.current = latest.version; + setCurrentVersion(latest.version); + setEditHistory((prev) => [...prev, ...ops]); + ops.forEach((op) => onRemoteOperation?.(op)); + } + }); + + socket.on('operation-ack', (data: { version: number }) => { + versionRef.current = data.version; + setCurrentVersion(data.version); + }); + + socket.on('field-locked', (data: { fieldPath: string; lockedBy: string }) => { + setLockedFields((prev) => ({ ...prev, [data.fieldPath]: data.lockedBy })); + }); + + socket.on('field-unlocked', (data: { fieldPath: string }) => { + setLockedFields((prev) => { + const next = { ...prev }; + delete next[data.fieldPath]; + return next; + }); + }); + + socket.on('rollback-snapshot', (data: { version: number; operations: EditOperation[] }) => { + setEditHistory(data.operations); + setCurrentVersion(data.version); + versionRef.current = data.version; + }); + + return () => { + socket.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, userId]); + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + const sendOperation = useCallback( + (op: Omit) => { + const fullOp: EditOperation = { + ...op, + projectId, + version: versionRef.current, + }; + + if (socketRef.current?.connected) { + socketRef.current.emit('operation', fullOp); + } else { + offlineQueueRef.current.push(fullOp); + } + }, + [projectId] + ); + + const moveCursor = useCallback( + (fieldPath: string) => { + socketRef.current?.emit('cursor-move', { projectId, fieldPath }); + }, + [projectId] + ); + + const acquireLock = useCallback( + (fieldPath: string): Promise => + new Promise((resolve) => { + const socket = socketRef.current; + if (!socket?.connected) { resolve(false); return; } + + socket.once('lock-result', (data: { fieldPath: string; granted: boolean }) => { + if (data.fieldPath === fieldPath) resolve(data.granted); + }); + + socket.emit('acquire-lock', { projectId, fieldPath }); + }), + [projectId] + ); + + const releaseLock = useCallback( + (fieldPath: string) => { + socketRef.current?.emit('release-lock', { projectId, fieldPath }); + }, + [projectId] + ); + + const rollbackTo = useCallback( + (version: number) => { + socketRef.current?.emit('rollback', { projectId, toVersion: version }); + }, + [projectId] + ); + + return { + isConnected, + participants, + lockedFields, + currentVersion, + sendOperation, + moveCursor, + acquireLock, + releaseLock, + rollbackTo, + editHistory, + }; +}