diff --git a/src/models/Escrow.ts b/src/models/Escrow.ts index bc76890..702f1f9 100644 --- a/src/models/Escrow.ts +++ b/src/models/Escrow.ts @@ -1,8 +1,16 @@ -import mongoose, { Schema, Document, Types, Model } from 'mongoose'; +// src/models/Escrow.ts +import mongoose, { Schema, Document, Model, Types } from 'mongoose'; -/** - * Lifecycle of funds held in a Soroban escrow contract for a delivery. - */ +/** Lifecycle of funds held in a Soroban escrow contract for a delivery. */ +export enum EscrowStatus { + PENDING = 'pending', + LOCKED = 'locked', + RELEASED = 'released', + REFUNDED = 'refunded', + DISPUTED = 'disputed', +} + +/** Alias for lock status – kept for backward compatibility. */ export enum EscrowLockStatus { PENDING = 'pending', LOCKED = 'locked', @@ -11,7 +19,19 @@ export enum EscrowLockStatus { DISPUTED = 'disputed', } -/** The kind of on-chain operation a recorded transaction hash represents. */ +/** Escrow states in which funds are actually held by the contract. */ +const FUNDS_HELD_STATUSES: ReadonlySet = new Set([ + EscrowStatus.LOCKED, + EscrowStatus.DISPUTED, +]); + +/** Escrow states that can no longer change. */ +const TERMINAL_STATUSES: ReadonlySet = new Set([ + EscrowStatus.RELEASED, + EscrowStatus.REFUNDED, +]); + +/** The kind of on‑chain operation a recorded transaction hash represents. */ export type EscrowTransactionType = 'fund' | 'release' | 'refund'; export interface IEscrowTransaction { @@ -22,82 +42,103 @@ export interface IEscrowTransaction { } export interface IEscrow extends Document { + /** Reference to the delivery this escrow secures. */ delivery: Types.ObjectId; - contractId: string; + /** Current escrow lifecycle state. */ + status: EscrowStatus; + /** Escrowed amount, denominated in `assetCode` units (not stroops). */ amount: number; - asset: string; - lockStatus: EscrowLockStatus; - fundedBy?: string; - transactions: IEscrowTransaction[]; + /** Asset code of the escrowed funds (e.g. `XLM`, `USDC`). */ + assetCode: string; + /** Issuer account for non‑native assets. */ + assetIssuer?: string; + /** Soroban contract id (`C...`) holding the funds. */ + contractId?: string; + /** Stellar account funding the escrow. */ + payerAddress?: string; + /** Stellar account entitled to the funds on release. */ + payeeAddress?: string; + /** Transaction hash of the successful lock invocation. */ + lockTransactionHash?: string; + /** Transaction hash of the successful release invocation. */ + releaseTransactionHash?: string; + /** Transaction hash of the successful refund invocation. */ + refundTransactionHash?: string; lockedAt?: Date; releasedAt?: Date; refundedAt?: Date; + /** Ledger sequence of the last on‑chain event applied to this record. */ + lastSyncedLedger?: number; + /** Reason recorded when the escrow moved to `disputed`. */ + disputeReason?: string; + /** Timestamp fields provided by Mongoose. */ createdAt: Date; updatedAt: Date; + /** Collection of on‑chain transaction hashes. */ + transactions: IEscrowTransaction[]; + /** Virtuals */ + readonly isFundsLocked: boolean; + readonly isSettled: boolean; } +// Schema definitions const EscrowTransactionSchema = new Schema( { hash: { type: String, required: true, trim: true }, - type: { - type: String, - enum: ['fund', 'release', 'refund'], - required: true, - }, + type: { type: String, enum: ['fund', 'release', 'refund'], required: true }, ledger: { type: Number }, recordedAt: { type: Date, default: Date.now }, }, - { _id: false }, + { _id: false } ); const EscrowSchema = new Schema( { - delivery: { - type: Schema.Types.ObjectId, - ref: 'Delivery', - required: true, - index: true, - }, - contractId: { - type: String, - required: true, - unique: true, - trim: true, - }, - amount: { - type: Number, - required: true, - min: 0, - }, - asset: { - type: String, - required: true, - trim: true, - }, - lockStatus: { - type: String, - enum: Object.values(EscrowLockStatus), - default: EscrowLockStatus.PENDING, - index: true, - }, - fundedBy: { type: String, trim: true }, - transactions: { - type: [EscrowTransactionSchema], - default: [], - }, + delivery: { type: Schema.Types.ObjectId, ref: 'Delivery', required: true, unique: true, index: true }, + status: { type: String, enum: Object.values(EscrowStatus), default: EscrowStatus.PENDING, required: true, index: true }, + amount: { type: Number, required: true, min: 0 }, + assetCode: { type: String, required: true, trim: true, uppercase: true, maxlength: 12 }, + assetIssuer: { type: String, trim: true }, + contractId: { type: String, trim: true }, + payerAddress: { type: String, trim: true }, + payeeAddress: { type: String, trim: true }, + lockTransactionHash: { type: String, trim: true }, + releaseTransactionHash: { type: String, trim: true }, + refundTransactionHash: { type: String, trim: true }, lockedAt: { type: Date }, releasedAt: { type: Date }, refundedAt: { type: Date }, + lastSyncedLedger: { type: Number, min: 0 }, + disputeReason: { type: String, trim: true }, + transactions: { type: [EscrowTransactionSchema], default: [] }, }, - { timestamps: true }, + { + timestamps: true, + toJSON: { + virtuals: true, + transform(_doc, ret: Record) { + ret.id = ret._id; + delete ret._id; + delete ret.__v; + return ret; + }, + }, + toObject: { virtuals: true }, + } ); -// A given on-chain transaction hash must only ever be recorded once across -// all escrows, preventing duplicate ingestion by the indexer. +// Virtuals +EscrowSchema.virtual('isFundsLocked').get(function (this: IEscrow) { + return FUNDS_HELD_STATUSES.has(this.status); +}); +EscrowSchema.virtual('isSettled').get(function (this: IEscrow) { + return TERMINAL_STATUSES.has(this.status); +}); + +// Ensure transaction hash uniqueness across escrows EscrowSchema.index({ 'transactions.hash': 1 }, { unique: true, sparse: true }); -const Escrow: Model = - (mongoose.models.Escrow as Model) || mongoose.model('Escrow', EscrowSchema); +const Escrow: Model = (mongoose.models.Escrow as Model) || mongoose.model('Escrow', EscrowSchema); export default Escrow; export { Escrow }; diff --git a/src/sockets/connectionHandler.ts b/src/sockets/connectionHandler.ts index 58ca242..414749d 100644 --- a/src/sockets/connectionHandler.ts +++ b/src/sockets/connectionHandler.ts @@ -13,6 +13,7 @@ import { SocketData, TypedSocket, } from './socket.types'; +import jwt from 'jsonwebtoken'; /** * Typed Socket.IO server alias used throughout the sockets layer. @@ -53,17 +54,16 @@ export function initializeSocketServer(httpServer: HttpServer): TypedServer { // ─── Per-connection setup ────────────────────────────────────────────────── io.on('connection', (socket: TypedSocket) => { - // Extract userId and JWT token from auth handshake data - const userId = extractUserId(socket); - const token = extractToken(socket); + // Extract authentication info from handshake + const { userId, tokenExp } = extractAuthInfo(socket); - // Store metadata on the socket data for easy access later + // Store auth data on socket data socket.data.connectedAt = Date.now(); - socket.data.userId = userId; - socket.data.token = token; + if (userId) socket.data.userId = userId; + if (tokenExp) (socket.data as any).tokenExp = tokenExp; - // Register the connection in the service layer - socketService.registerConnection(socket, userId); + // Register the connection in the service layer with token expiration + socketService.registerConnection(socket, userId, tokenExp); // ── offline sync handler ───────────────────────────────────────────────── registerSyncHandler(socket); @@ -71,6 +71,31 @@ export function initializeSocketServer(httpServer: HttpServer): TypedServer { // ── real-time location broadcast handler ───────────────────────────────── registerLocationHandler(io, socket); + // ── token refresh handler ───────────────────────────────────────────────────── + socket.on('refresh_token', (payload: { token: string }) => { + const token = payload?.token; + if (!token) { + logger.warn(`[Socket] refresh_token missing token – socketId=${socket.id}`); + socket.emit('auth_expired'); + socket.disconnect(true); + return; + } + try { + const rawToken = token.startsWith('Bearer ') ? token.slice(7) : token; + const decoded = jwt.verify(rawToken, process.env.JWT_SECRET as string) as { userId?: string; exp?: number }; + const newExp = typeof decoded.exp === 'number' ? decoded.exp * 1000 : undefined; + if (newExp) { + (socket.data as any).tokenExp = newExp; + socketService.updateTokenExpiration(socket.id, newExp); + logger.info(`[Socket] Token refreshed for socketId=${socket.id}`); + } + } catch (err) { + logger.warn(`Token refresh verification failed for socket ${socket.id}: ${(err as Error).message}`); + socket.emit('auth_expired'); + socket.disconnect(true); + } + }); + // ── pong handler ──────────────────────────────────────────────────────── socket.on('pong', (payload: PongPayload) => { socketService.handlePong(socket, payload); @@ -167,20 +192,25 @@ export async function shutdownSocketServer(io: TypedServer): Promise { * @param socket - The connecting socket. * @returns The userId string, or undefined if absent. */ -function extractUserId(socket: TypedSocket): string | undefined { +function extractAuthInfo(socket: TypedSocket): { userId?: string; tokenExp?: number } { const auth = socket.handshake.auth as Record; + const token = typeof auth?.token === 'string' ? auth.token : undefined; - if (typeof auth?.userId === 'string' && auth.userId.trim()) { - return auth.userId.trim(); + if (!token) { + return {}; } - // Fallback: check query params (useful for testing with Postman) - const queryUserId = socket.handshake.query?.userId; - if (typeof queryUserId === 'string' && queryUserId.trim()) { - return queryUserId.trim(); + try { + const rawToken = token.startsWith('Bearer ') ? token.slice(7) : token; + const decoded = jwt.verify(rawToken, process.env.JWT_SECRET as string) as { userId?: string; exp?: number }; + return { + userId: typeof decoded.userId === 'string' ? decoded.userId : undefined, + tokenExp: typeof decoded.exp === 'number' ? decoded.exp * 1000 : undefined, + }; + } catch (err) { + logger.warn(`JWT verification failed for socket ${socket.id}: ${(err as Error).message}`); + return {}; } - - return undefined; } /** diff --git a/src/sockets/socket.service.ts b/src/sockets/socket.service.ts index 7fcf44d..e297179 100644 --- a/src/sockets/socket.service.ts +++ b/src/sockets/socket.service.ts @@ -45,8 +45,9 @@ export class SocketService { * @param socket - The incoming socket instance. * @param userId - Optional authenticated user ID extracted from auth token. */ - public registerConnection(socket: TypedSocket, userId?: string): void { + public registerConnection(socket: TypedSocket, userId?: string, tokenExp?: number): void { const meta: SocketConnectionMeta = { + tokenExp: tokenExp, socketId: socket.id, userId, connectedAt: Date.now(), @@ -230,12 +231,22 @@ export class SocketService { staleConnectionsEvicted += 1; } else { // Send ping and wait for pong response - const pingPayload: PingPayload = { timestamp: Date.now() }; - const socket = io.sockets.sockets.get(socketId); - if (socket) { + // Send ping and wait for pong response + const pingPayload: PingPayload = { timestamp: Date.now() }; + const socket = io.sockets.sockets.get(socketId); + if (socket) { + // Check JWT expiration before sending ping + const exp = (socket.data as any).tokenExp as number | undefined; + if (exp && exp < Date.now()) { + // Token has expired – notify client and disconnect + logger.warn(`[Socket] JWT expired for socket id=${socketId}`); + socket.emit('auth_expired'); + socket.disconnect(true); + } else { socket.emit('ping', pingPayload); } } + } } const result: HealthCheckResult = { diff --git a/src/sockets/socket.types.ts b/src/sockets/socket.types.ts index 728f7bd..3403119 100644 --- a/src/sockets/socket.types.ts +++ b/src/sockets/socket.types.ts @@ -16,6 +16,8 @@ export interface SocketConnectionMeta { missedPongs: number; /** Rooms the socket is currently a member of */ rooms: string[]; + /** Optional JWT expiration timestamp (ms since epoch) */ + tokenExp?: number; } /** @@ -188,10 +190,8 @@ export interface ServerToClientEvents { 'location:update': (payload: LocationBroadcastPayload) => void; /** Ack sent back to the driver after a live location update is processed. */ location_update_ack: (payload: LocationUpdateAck) => void; - /** Emitted when the server detects an expired or invalid JWT during an active session. */ - auth_expired: (payload: AuthExpiredPayload) => void; - /** Ack sent back after the client submits a refreshed JWT via `auth_refresh`. */ - auth_refresh_ack: (payload: AuthRefreshAckPayload) => void; + /** Notify client that authentication token has expired */ + auth_expired: () => void; } /** @@ -225,6 +225,8 @@ export interface SocketData { userId?: string; token?: string; connectedAt: number; + /** JWT expiration timestamp in ms */ + tokenExp?: number; } /**