diff --git a/.env.example b/.env.example index dc88458d8..048c9d7ec 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,8 @@ PUBLIC_APP_STORE_EID_WALLET="" PUBLIC_PLAY_STORE_EID_WALLET="" NOTIFICATION_SHARED_SECRET=your-notification-secret-key +PUBLIC_ESIGNER_BASE_URL="http://localhost:3004" + DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" diff --git a/.github/workflows/check-code.yml b/.github/workflows/check-code.yml index c34669b76..f46470557 100644 --- a/.github/workflows/check-code.yml +++ b/.github/workflows/check-code.yml @@ -26,4 +26,6 @@ jobs: pnpm i - name: Check Code - run: pnpm check + run: | + cp .env.example .env + pnpm check diff --git a/platforms/esigner-api/package.json b/platforms/esigner-api/package.json new file mode 100644 index 000000000..80bccf0ff --- /dev/null +++ b/platforms/esigner-api/package.json @@ -0,0 +1,48 @@ +{ + "name": "esigner-api", + "version": "1.0.0", + "description": "eSigner Document Signing Platform API", + "main": "src/index.ts", + "scripts": { + "start": "ts-node src/index.ts", + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc && cp -r src/web3adapter/mappings dist/web3adapter/", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "npm run typeorm migration:generate -- -d src/database/data-source.ts", + "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", + "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts" + }, + "dependencies": { + "axios": "^1.6.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "eventsource-polyfill": "^0.9.6", + "express": "^4.18.2", + "graphql-request": "^6.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.1", + "typeorm": "^0.3.24", + "uuid": "^9.0.1", + "signature-validator": "workspace:*", + "web3-adapter": "workspace:*" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/multer": "^1.4.11", + "@types/node": "^20.11.24", + "@types/pg": "^8.11.2", + "@types/uuid": "^9.0.8", + "@typescript-eslint/eslint-plugin": "^7.0.1", + "@typescript-eslint/parser": "^7.0.1", + "eslint": "^8.56.0", + "nodemon": "^3.0.3", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} + + diff --git a/platforms/esigner-api/src/controllers/AuthController.ts b/platforms/esigner-api/src/controllers/AuthController.ts new file mode 100644 index 000000000..1870ae8f9 --- /dev/null +++ b/platforms/esigner-api/src/controllers/AuthController.ts @@ -0,0 +1,134 @@ +import { Request, Response } from "express"; +import { v4 as uuidv4 } from "uuid"; +import { UserService } from "../services/UserService"; +import { EventEmitter } from "events"; +import { signToken } from "../utils/jwt"; +import { isVersionValid } from "../utils/version"; +import { verifySignature } from "signature-validator"; + +const MIN_REQUIRED_VERSION = "0.4.0"; + +export class AuthController { + private userService: UserService; + private eventEmitter: EventEmitter; + + constructor() { + this.userService = new UserService(); + this.eventEmitter = new EventEmitter(); + } + + sseStream = async (req: Request, res: Response) => { + const { id } = req.params; + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const handler = (data: any) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + this.eventEmitter.on(id, handler); + + req.on("close", () => { + this.eventEmitter.off(id, handler); + res.end(); + }); + + req.on("error", (error) => { + console.error("SSE Error:", error); + this.eventEmitter.off(id, handler); + res.end(); + }); + }; + + getOffer = async (req: Request, res: Response) => { + const url = new URL( + "/api/auth", + process.env.PUBLIC_ESIGNER_BASE_URL, + ).toString(); + const session = uuidv4(); + const offer = `w3ds://auth?redirect=${url}&session=${session}&platform=esigner`; + res.json({ uri: offer }); + }; + + login = async (req: Request, res: Response) => { + try { + const { ename, session, appVersion, signature } = req.body; + + if (!ename) { + return res.status(400).json({ error: "ename is required" }); + } + + if (!session) { + return res.status(400).json({ error: "session is required" }); + } + + if (!signature) { + return res.status(400).json({ error: "signature is required" }); + } + + if (!appVersion || !isVersionValid(appVersion, MIN_REQUIRED_VERSION)) { + const errorMessage = { + error: true, + message: `Your eID Wallet app version is outdated. Please update to version ${MIN_REQUIRED_VERSION} or later.`, + type: "version_mismatch" + }; + this.eventEmitter.emit(session, errorMessage); + return res.status(400).json({ + error: "App version too old", + message: errorMessage.message + }); + } + + const registryBaseUrl = process.env.PUBLIC_REGISTRY_URL; + if (!registryBaseUrl) { + console.error("PUBLIC_REGISTRY_URL not configured"); + return res.status(500).json({ error: "Server configuration error" }); + } + + const verificationResult = await verifySignature({ + eName: ename, + signature: signature, + payload: session, + registryBaseUrl: registryBaseUrl, + }); + + if (!verificationResult.valid) { + console.error("Signature validation failed:", verificationResult.error); + return res.status(401).json({ + error: "Invalid signature", + message: verificationResult.error + }); + } + + let user = await this.userService.findByEname(ename); + + if (!user) { + throw new Error("User not found"); + } + + const token = signToken({ userId: user.id }); + + const data = { + user: { + id: user.id, + ename: user.ename, + isVerified: user.isVerified, + isPrivate: user.isPrivate, + }, + token, + }; + this.eventEmitter.emit(session, data); + res.status(200).json(data); + } catch (error) { + console.error("Error during login:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + + diff --git a/platforms/esigner-api/src/controllers/FileController.ts b/platforms/esigner-api/src/controllers/FileController.ts new file mode 100644 index 000000000..665d97a76 --- /dev/null +++ b/platforms/esigner-api/src/controllers/FileController.ts @@ -0,0 +1,220 @@ +import { Request, Response } from "express"; +import { FileService } from "../services/FileService"; +import multer from "multer"; + +const upload = multer({ + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit + storage: multer.memoryStorage(), +}); + +export class FileController { + private fileService: FileService; + + constructor() { + this.fileService = new FileService(); + } + + uploadFile = [ + upload.single('file'), + async (req: Request, res: Response) => { + try { + if (!req.file) { + return res.status(400).json({ error: "No file provided" }); + } + + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { displayName, description } = req.body; + + const file = await this.fileService.createFile( + req.file.originalname, + req.file.mimetype, + req.file.size, + req.file.buffer, + req.user.id, + displayName, + description + ); + + res.status(201).json({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + createdAt: file.createdAt, + }); + } catch (error) { + console.error("Error uploading file:", error); + res.status(500).json({ error: "Failed to upload file" }); + } + } + ]; + + getFiles = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const documents = await this.fileService.getDocumentsWithStatus(req.user.id); + res.json(documents); + } catch (error) { + console.error("Error getting documents:", error); + res.status(500).json({ error: "Failed to get documents" }); + } + }; + + getFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const file = await this.fileService.getFileById(id, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + res.json({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + ownerId: file.ownerId, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }); + } catch (error) { + console.error("Error getting file:", error); + res.status(500).json({ error: "Failed to get file" }); + } + }; + + updateFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { displayName, description } = req.body; + + const file = await this.fileService.updateFile( + id, + req.user.id, + displayName, + description + ); + + if (!file) { + return res.status(404).json({ error: "File not found or not authorized" }); + } + + res.json({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + ownerId: file.ownerId, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }); + } catch (error) { + console.error("Error updating file:", error); + res.status(500).json({ error: "Failed to update file" }); + } + }; + + downloadFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const file = await this.fileService.getFileById(id, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + res.setHeader('Content-Type', file.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${file.name}"`); + res.setHeader('Content-Length', file.size.toString()); + res.send(file.data); + } catch (error) { + console.error("Error downloading file:", error); + res.status(500).json({ error: "Failed to download file" }); + } + }; + + deleteFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const deleted = await this.fileService.deleteFile(id, req.user.id); + + if (!deleted) { + return res.status(404).json({ error: "File not found or not authorized" }); + } + + res.json({ message: "File deleted successfully" }); + } catch (error) { + console.error("Error deleting file:", error); + res.status(500).json({ error: "Failed to delete file" }); + } + }; + + getFileSignatures = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { fileId } = req.params; + const file = await this.fileService.getFileById(fileId, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + const signatures = await this.fileService.getFileSignatures(fileId); + + res.json(signatures.map(sig => ({ + id: sig.id, + userId: sig.userId, + user: sig.user ? { + id: sig.user.id, + name: sig.user.name, + ename: sig.user.ename, + avatarUrl: sig.user.avatarUrl, + } : null, + md5Hash: sig.md5Hash, + message: sig.message, + signature: sig.signature, + publicKey: sig.publicKey, + createdAt: sig.createdAt, + }))); + } catch (error) { + console.error("Error getting file signatures:", error); + res.status(500).json({ error: "Failed to get signatures" }); + } + }; +} + diff --git a/platforms/esigner-api/src/controllers/InvitationController.ts b/platforms/esigner-api/src/controllers/InvitationController.ts new file mode 100644 index 000000000..f8b33913f --- /dev/null +++ b/platforms/esigner-api/src/controllers/InvitationController.ts @@ -0,0 +1,137 @@ +import { Request, Response } from "express"; +import { InvitationService } from "../services/InvitationService"; + +export class InvitationController { + private invitationService: InvitationService; + + constructor() { + this.invitationService = new InvitationService(); + } + + inviteSignees = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { fileId } = req.params; + const { userIds } = req.body; + + // Allow empty array for self-signed documents (owner will be automatically added) + if (!userIds || !Array.isArray(userIds)) { + return res.status(400).json({ error: "userIds must be an array" }); + } + + const invitations = await this.invitationService.inviteSignees( + fileId, + userIds, + req.user.id + ); + + res.status(201).json({ + message: "Invitations sent successfully", + invitations: invitations.map(inv => ({ + id: inv.id, + fileId: inv.fileId, + userId: inv.userId, + status: inv.status, + invitedAt: inv.invitedAt, + })), + }); + } catch (error) { + console.error("Error inviting signees:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to invite signees" }); + } + }; + + getFileInvitations = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { fileId } = req.params; + const invitations = await this.invitationService.getFileInvitations(fileId, req.user.id); + + res.json(invitations.map(inv => ({ + id: inv.id, + fileId: inv.fileId, + userId: inv.userId, + user: inv.user ? { + id: inv.user.id, + name: inv.user.name, + ename: inv.user.ename, + avatarUrl: inv.user.avatarUrl, + } : null, + status: inv.status, + invitedAt: inv.invitedAt, + signedAt: inv.signedAt, + declinedAt: inv.declinedAt, + }))); + } catch (error) { + console.error("Error getting file invitations:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to get invitations" }); + } + }; + + getUserInvitations = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const invitations = await this.invitationService.getUserInvitations(req.user.id); + + res.json(invitations.map(inv => ({ + id: inv.id, + fileId: inv.fileId, + file: inv.file ? { + id: inv.file.id, + name: inv.file.name, + displayName: inv.file.displayName, + description: inv.file.description, + mimeType: inv.file.mimeType, + size: inv.file.size, + ownerId: inv.file.ownerId, + createdAt: inv.file.createdAt, + } : null, + status: inv.status, + invitedAt: inv.invitedAt, + }))); + } catch (error) { + console.error("Error getting user invitations:", error); + res.status(500).json({ error: "Failed to get invitations" }); + } + }; + + declineInvitation = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const declined = await this.invitationService.declineInvitation(id, req.user.id); + + if (!declined) { + return res.status(404).json({ error: "Invitation not found" }); + } + + res.json({ message: "Invitation declined successfully" }); + } catch (error) { + console.error("Error declining invitation:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to decline invitation" }); + } + }; +} + + diff --git a/platforms/esigner-api/src/controllers/SignatureController.ts b/platforms/esigner-api/src/controllers/SignatureController.ts new file mode 100644 index 000000000..024dd8009 --- /dev/null +++ b/platforms/esigner-api/src/controllers/SignatureController.ts @@ -0,0 +1,143 @@ +import { Request, Response } from "express"; +import { SignatureService } from "../services/SignatureService"; + +export class SignatureController { + private signatureService: SignatureService; + + constructor() { + this.signatureService = new SignatureService(); + } + + createSigningSession = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { fileId } = req.body; + + if (!fileId) { + return res.status(400).json({ error: "fileId is required" }); + } + + const session = await this.signatureService.createSession(fileId, req.user.id); + + res.json({ + sessionId: session.sessionId, + qrData: session.qrData, + expiresAt: session.expiresAt + }); + } catch (error) { + console.error("Error creating signing session:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to create signing session" }); + } + }; + + getSigningSessionStatus = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + if (!id) { + return res.status(400).json({ error: "Session ID is required" }); + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + }); + + const session = await this.signatureService.getSessionStatus(id); + if (session) { + res.write(`data: ${JSON.stringify({ type: "status", status: session.status })}\n\n`); + } + + const interval = setInterval(async () => { + const session = await this.signatureService.getSessionStatus(id); + + if (session) { + if (session.status === "completed") { + res.write(`data: ${JSON.stringify({ + type: "signed", + status: "completed", + fileId: session.fileId + })}\n\n`); + clearInterval(interval); + res.end(); + } else if (session.status === "expired") { + res.write(`data: ${JSON.stringify({ type: "expired" })}\n\n`); + clearInterval(interval); + res.end(); + } else if (session.status === "security_violation") { + res.write(`data: ${JSON.stringify({ type: "security_violation" })}\n\n`); + clearInterval(interval); + res.end(); + } + } else { + res.write(`data: ${JSON.stringify({ type: "error", message: "Session not found" })}\n\n`); + clearInterval(interval); + res.end(); + } + }, 1000); + + req.on('close', () => { + clearInterval(interval); + res.end(); + }); + + } catch (error) { + console.error("Error getting signing session status:", error); + res.status(500).json({ error: "Failed to get signing session status" }); + } + }; + + handleSignedPayload = async (req: Request, res: Response) => { + try { + const { sessionId, signature, w3id, message } = req.body; + + const missingFields = []; + if (!sessionId) missingFields.push('sessionId'); + if (!signature) missingFields.push('signature'); + if (!w3id) missingFields.push('w3id'); + if (!message) missingFields.push('message'); + + if (missingFields.length > 0) { + return res.status(400).json({ + error: `Missing required fields: ${missingFields.join(', ')}` + }); + } + + const result = await this.signatureService.processSignedPayload( + sessionId, + signature, + w3id, + message + ); + + if (result.success) { + res.json({ + success: true, + message: "Signature verified and stored", + data: result + }); + } else { + res.status(200).json({ + success: false, + error: result.error, + message: "Request processed but signature not stored due to verification failure" + }); + } + + } catch (error) { + console.error("Error processing signed payload:", error); + res.status(500).json({ error: "Failed to process signed payload" }); + } + }; +} + + diff --git a/platforms/esigner-api/src/controllers/UserController.ts b/platforms/esigner-api/src/controllers/UserController.ts new file mode 100644 index 000000000..4c6fa21e2 --- /dev/null +++ b/platforms/esigner-api/src/controllers/UserController.ts @@ -0,0 +1,58 @@ +import { Request, Response } from "express"; +import { UserService } from "../services/UserService"; + +export class UserController { + private userService: UserService; + + constructor() { + this.userService = new UserService(); + } + + currentUser = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + res.json({ + id: req.user.id, + name: req.user.name, + ename: req.user.ename, + handle: req.user.handle, + avatarUrl: req.user.avatarUrl, + isVerified: req.user.isVerified, + }); + } catch (error) { + console.error("Error getting current user:", error); + res.status(500).json({ error: "Failed to get current user" }); + } + }; + + search = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { query, page = 1, limit = 10 } = req.query; + + if (!query || typeof query !== 'string') { + return res.status(400).json({ error: "Query parameter is required" }); + } + + const users = await this.userService.searchUsers( + query, + Number(page), + Number(limit), + false, + "relevance" + ); + + res.json(users); + } catch (error) { + console.error("Error searching users:", error); + res.status(500).json({ error: "Failed to search users" }); + } + }; +} + diff --git a/platforms/esigner-api/src/controllers/WebhookController.ts b/platforms/esigner-api/src/controllers/WebhookController.ts new file mode 100644 index 000000000..b86b7b029 --- /dev/null +++ b/platforms/esigner-api/src/controllers/WebhookController.ts @@ -0,0 +1,255 @@ +import { Request, Response } from "express"; +import { UserService } from "../services/UserService"; +import { GroupService } from "../services/GroupService"; +import { MessageService } from "../services/MessageService"; +import { Web3Adapter } from "web3-adapter"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; +import { Message } from "../database/entities/Message"; +import axios from "axios"; + +export class WebhookController { + userService: UserService; + groupService: GroupService; + messageService: MessageService; + adapter: Web3Adapter; + + constructor(adapter: Web3Adapter) { + this.userService = new UserService(); + this.groupService = new GroupService(); + this.messageService = new MessageService(); + this.adapter = adapter; + } + + handleWebhook = async (req: Request, res: Response) => { + try { + if (process.env.ANCHR_URL) { + axios.post( + new URL("esigner", process.env.ANCHR_URL).toString(), + req.body + ); + } + const schemaId = req.body.schemaId; + const globalId = req.body.id; + const mapping = Object.values(this.adapter.mapping).find( + (m) => m.schemaId === schemaId + ); + this.adapter.addToLockedIds(globalId); + + if (!mapping) { + return res.status(400).json({ error: "Unknown schema" }); + } + + const local = await this.adapter.fromGlobal({ + data: req.body.data, + mapping, + }); + + let localId = await this.adapter.mappingDb.getLocalId(globalId); + + if (mapping.tableName === "users") { + if (localId) { + const user = await this.userService.findById(localId); + if (!user) throw new Error("User not found"); + + for (const key of Object.keys(local.data)) { + // @ts-ignore + user[key] = local.data[key] ?? user[key]; + } + user.name = req.body.data.displayName; + await this.userService.userRepository.save(user); + await this.adapter.mappingDb.storeMapping({ + localId: user.id, + globalId: req.body.id, + }); + this.adapter.addToLockedIds(user.id); + this.adapter.addToLockedIds(globalId); + } else { + const { user } = await this.userService.findOrCreateUser( + req.body.w3id + ); + for (const key of Object.keys(local.data)) { + // @ts-ignore + user[key] = local.data[key]; + } + user.name = req.body.data.displayName; + await this.userService.userRepository.save(user); + await this.adapter.mappingDb.storeMapping({ + localId: user.id, + globalId: req.body.id, + }); + this.adapter.addToLockedIds(user.id); + this.adapter.addToLockedIds(globalId); + } + } else if (mapping.tableName === "groups") { + let participants: User[] = []; + if ( + local.data.participants && + Array.isArray(local.data.participants) + ) { + const participantPromises = local.data.participants.map( + async (ref: string) => { + if (ref && typeof ref === "string") { + const userId = ref.split("(")[1].split(")")[0]; + return await this.userService.getUserById(userId); + } + return null; + } + ); + + participants = ( + await Promise.all(participantPromises) + ).filter((user: User | null): user is User => user !== null); + } + + let adminIds = local?.data?.admins as string[] ?? [] + adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + + if (localId) { + const group = await this.groupService.getGroupById(localId); + if (!group) { + return res.status(500).send(); + } + + group.name = local.data.name as string; + group.description = local.data.description as string; + group.owner = local.data.owner as string; + group.admins = adminIds.map(id => ({ id } as User)); + group.participants = participants; + group.charter = local.data.charter as string; + group.ename = local.data.ename as string; + if (local.data.originalMatchParticipants) { + group.originalMatchParticipants = local.data.originalMatchParticipants as string[]; + } + + this.adapter.addToLockedIds(localId); + await this.groupService.groupRepository.save(group); + localId = group.id; + } else { + // Check if a group with the same name and description already exists + // This prevents duplicate group creation from junction table webhooks + const existingGroup = await this.groupService.groupRepository.findOne({ + where: { + name: local.data.name as string, + description: local.data.description as string + } + }); + + if (existingGroup) { + this.adapter.addToLockedIds(existingGroup.id); + await this.adapter.mappingDb.storeMapping({ + localId: existingGroup.id, + globalId: req.body.id, + }); + localId = existingGroup.id; + } else { + const group = await this.groupService.createGroup( + local.data.name as string, + local.data.description as string, + local.data.owner as string, + adminIds, + participants.map(p => p.id), + local.data.charter as string | undefined, + local.data.isPrivate as boolean | undefined, + local.data.visibility as "public" | "private" | "restricted" | undefined, + local.data.avatarUrl as string | undefined, + local.data.bannerUrl as string | undefined, + local.data.originalMatchParticipants as string[] | undefined, + ); + this.adapter.addToLockedIds(group.id); + await this.adapter.mappingDb.storeMapping({ + localId: group.id, + globalId: req.body.id, + }); + localId = group.id; + } + } + } else if (mapping.tableName === "messages") { + console.log("Processing message with data:", local.data); + + // Extract sender and group from the message data + let sender: User | null = null; + let group: Group | null = null; + + if (local.data.sender && typeof local.data.sender === "string") { + const senderId = local.data.sender.split("(")[1].split(")")[0]; + sender = await this.userService.getUserById(senderId); + } + + if (local.data.group && typeof local.data.group === "string") { + const groupId = local.data.group.split("(")[1].split(")")[0]; + group = await this.groupService.getGroupById(groupId); + } + + // Check if this is a system message (no sender required) + const isSystemMessage = local.data.isSystemMessage === true || + (local.data.text && typeof local.data.text === 'string' && local.data.text.startsWith('$$system-message$$')); + + if (!group) { + console.error("Group not found for message"); + return res.status(500).send(); + } + + // For system messages, sender can be null + if (!isSystemMessage && !sender) { + console.error("Sender not found for non-system message"); + return res.status(500).send(); + } + + if (localId) { + console.log("Updating existing message with localId:", localId); + const message = await this.messageService.getMessageById(localId); + if (!message) { + console.error("Message not found for localId:", localId); + return res.status(500).send(); + } + + // For system messages, ensure the prefix is preserved + if (isSystemMessage && !(local.data.text as string).startsWith('$$system-message$$')) { + message.text = `$$system-message$$ ${local.data.text as string}`; + } else { + message.text = local.data.text as string; + } + message.sender = sender || undefined; + message.group = group; + message.isSystemMessage = isSystemMessage as boolean; + + this.adapter.addToLockedIds(localId); + await this.messageService.messageRepository.save(message); + console.log("Updated message:", message.id); + } else { + console.log("Creating new message"); + let message: Message; + + if (isSystemMessage) { + message = await this.messageService.createSystemMessageWithoutPrefix({ + text: local.data.text as string, + groupId: group.id, + }); + } else { + message = await this.messageService.createMessage({ + text: local.data.text as string, + senderId: sender!.id, // We know sender exists for non-system messages + groupId: group.id, + }); + } + + console.log("Created message with ID:", message.id); + this.adapter.addToLockedIds(message.id); + await this.adapter.mappingDb.storeMapping({ + localId: message.id, + globalId: req.body.id, + }); + console.log("Stored mapping for message:", message.id, "->", req.body.id); + } + } + + res.status(200).json({ success: true }); + } catch (error) { + console.error("Error handling webhook:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + + diff --git a/platforms/esigner-api/src/database/data-source.ts b/platforms/esigner-api/src/database/data-source.ts new file mode 100644 index 000000000..969c6f85c --- /dev/null +++ b/platforms/esigner-api/src/database/data-source.ts @@ -0,0 +1,32 @@ +import "reflect-metadata"; +import { DataSource } from "typeorm"; +import { config } from "dotenv"; +import { User } from "./entities/User"; +import { Group } from "./entities/Group"; +import { File } from "./entities/File"; +import { FileSignee } from "./entities/FileSignee"; +import { SignatureContainer } from "./entities/SignatureContainer"; +import { Message } from "./entities/Message"; +import { UserEVaultMapping } from "./entities/UserEVaultMapping"; +import path from "path"; +import { PostgresSubscriber } from "../web3adapter/watchers/subscriber"; + +config({ path: path.resolve(__dirname, "../../../../.env") }); + +export const AppDataSource = new DataSource({ + type: "postgres", + url: process.env.ESIGNER_DATABASE_URL, + synchronize: false, + logging: process.env.NODE_ENV === "development", + entities: [User, Group, File, FileSignee, SignatureContainer, Message, UserEVaultMapping], + migrations: [path.join(__dirname, "migrations", "*.ts")], + subscribers: [PostgresSubscriber], + ssl: process.env.DB_CA_CERT + ? { + rejectUnauthorized: false, + ca: process.env.DB_CA_CERT, + } + : false, +}); + + diff --git a/platforms/esigner-api/src/database/entities/File.ts b/platforms/esigner-api/src/database/entities/File.ts new file mode 100644 index 000000000..1a6966808 --- /dev/null +++ b/platforms/esigner-api/src/database/entities/File.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from "typeorm"; +import { User } from "./User"; +import { FileSignee } from "./FileSignee"; +import { SignatureContainer } from "./SignatureContainer"; + +@Entity("files") +export class File { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + name!: string; // Original file name + + @Column({ type: "varchar", nullable: true }) + displayName!: string | null; // Custom name for the signature container + + @Column({ type: "text", nullable: true }) + description!: string | null; // Optional description + + @Column() + mimeType!: string; + + @Column("bigint") + size!: number; + + @Column({ type: "text" }) + md5Hash!: string; + + @Column({ type: "bytea" }) + data!: Buffer; + + @Column() + ownerId!: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "ownerId" }) + owner!: User; + + @OneToMany(() => FileSignee, (fileSignee) => fileSignee.file) + signees!: FileSignee[]; + + @OneToMany(() => SignatureContainer, (signature) => signature.file) + signatures!: SignatureContainer[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + + diff --git a/platforms/esigner-api/src/database/entities/FileSignee.ts b/platforms/esigner-api/src/database/entities/FileSignee.ts new file mode 100644 index 000000000..e3931420c --- /dev/null +++ b/platforms/esigner-api/src/database/entities/FileSignee.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToOne, +} from "typeorm"; +import { File } from "./File"; +import { User } from "./User"; +import { SignatureContainer } from "./SignatureContainer"; + +@Entity("file_signees") +export class FileSignee { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + fileId!: string; + + @Column() + userId!: string; + + @Column() + invitedBy!: string; + + @Column({ + type: "varchar", + default: "pending" + }) + status!: "pending" | "signed" | "declined"; + + @CreateDateColumn() + invitedAt!: Date; + + @Column({ nullable: true }) + signedAt!: Date; + + @Column({ nullable: true }) + declinedAt!: Date; + + @ManyToOne(() => File) + @JoinColumn({ name: "fileId" }) + file!: File; + + @ManyToOne(() => User) + @JoinColumn({ name: "userId" }) + user!: User; + + @ManyToOne(() => User) + @JoinColumn({ name: "invitedBy" }) + inviter!: User; + + @OneToOne(() => SignatureContainer, (signature) => signature.fileSignee, { nullable: true }) + signature!: SignatureContainer | null; +} + diff --git a/platforms/esigner-api/src/database/entities/Group.ts b/platforms/esigner-api/src/database/entities/Group.ts new file mode 100644 index 000000000..cdd68f790 --- /dev/null +++ b/platforms/esigner-api/src/database/entities/Group.ts @@ -0,0 +1,83 @@ +import { + Entity, + CreateDateColumn, + UpdateDateColumn, + PrimaryGeneratedColumn, + Column, + ManyToMany, + OneToMany, + JoinTable, +} from "typeorm"; +import { User } from "./User"; +import { Message } from "./Message"; + +@Entity() +export class Group { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true }) + description!: string; + + @Column({ nullable: true }) + owner!: string; + + @Column({ type: "text", nullable: true }) + charter!: string; + + @Column({ default: false }) + isPrivate!: boolean; + + @Column({ default: "public" }) + visibility!: "public" | "private" | "restricted"; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_members", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + members!: User[]; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_admins", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + admins!: User[]; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_participants", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + participants!: User[]; + + @Column({ nullable: true }) + ename!: string; + + @Column({ nullable: true }) + avatarUrl!: string; + + @Column({ nullable: true }) + bannerUrl!: string; + + @Column({ type: "json", nullable: true }) + originalMatchParticipants!: string[]; // Store user IDs from the original match + + @OneToMany(() => Message, (message) => message.group) + messages!: Message[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + + diff --git a/platforms/esigner-api/src/database/entities/Message.ts b/platforms/esigner-api/src/database/entities/Message.ts new file mode 100644 index 000000000..18793ae44 --- /dev/null +++ b/platforms/esigner-api/src/database/entities/Message.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, +} from "typeorm"; +import { User } from "./User"; +import { Group } from "./Group"; + +@Entity("messages") +export class Message { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @ManyToOne(() => User, { nullable: true }) + sender?: User; // Nullable for system messages + + @Column("text") + text!: string; + + @ManyToOne(() => Group, (group) => group.messages) + group!: Group; + + @Column({ default: false }) + isSystemMessage!: boolean; // Flag to identify system messages + + @Column("uuid", { nullable: true }) + voteId?: string; // ID of the vote/poll this system message relates to + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ default: false }) + isArchived!: boolean; +} + diff --git a/platforms/esigner-api/src/database/entities/SignatureContainer.ts b/platforms/esigner-api/src/database/entities/SignatureContainer.ts new file mode 100644 index 000000000..5676e536c --- /dev/null +++ b/platforms/esigner-api/src/database/entities/SignatureContainer.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToOne, + JoinColumn, +} from "typeorm"; +import { File } from "./File"; +import { User } from "./User"; +import { FileSignee } from "./FileSignee"; + +@Entity("signature_containers") +export class SignatureContainer { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + fileId!: string; + + @Column() + userId!: string; + + @Column({ nullable: true }) + fileSigneeId!: string; + + @Column({ type: "text" }) + md5Hash!: string; + + @Column({ type: "text" }) + signature!: string; + + @Column({ type: "text" }) + publicKey!: string; + + @Column({ type: "text" }) + message!: string; + + @ManyToOne(() => File) + @JoinColumn({ name: "fileId" }) + file!: File; + + @ManyToOne(() => User) + @JoinColumn({ name: "userId" }) + user!: User; + + @OneToOne(() => FileSignee, (fileSignee) => fileSignee.signature, { nullable: true }) + @JoinColumn({ name: "fileSigneeId" }) + fileSignee!: FileSignee | null; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + + diff --git a/platforms/esigner-api/src/database/entities/User.ts b/platforms/esigner-api/src/database/entities/User.ts new file mode 100644 index 000000000..4a0db48ba --- /dev/null +++ b/platforms/esigner-api/src/database/entities/User.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("users") +export class User { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true }) + handle!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true }) + description!: string; + + @Column({ nullable: true }) + avatarUrl!: string; + + @Column({ nullable: true }) + bannerUrl!: string; + + @Column({ nullable: true }) + ename!: string; + + @Column({ default: false }) + isVerified!: boolean; + + @Column({ default: false }) + isPrivate!: boolean; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ default: false }) + isArchived!: boolean; +} + + diff --git a/platforms/esigner-api/src/database/entities/UserEVaultMapping.ts b/platforms/esigner-api/src/database/entities/UserEVaultMapping.ts new file mode 100644 index 000000000..4b1ccf983 --- /dev/null +++ b/platforms/esigner-api/src/database/entities/UserEVaultMapping.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("user_evault_mappings") +export class UserEVaultMapping { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + localUserId!: string; + + @Column() + evaultW3id!: string; + + @Column() + evaultUri!: string; + + @Column({ nullable: true }) + userProfileId!: string; // ID of the UserProfile object in the eVault + + @Column({ type: "jsonb", nullable: true }) + userProfileData!: any; // Store the UserProfile data + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/esigner-api/src/database/migrations/1767471841456-migration.ts b/platforms/esigner-api/src/database/migrations/1767471841456-migration.ts new file mode 100644 index 000000000..a754e43f2 --- /dev/null +++ b/platforms/esigner-api/src/database/migrations/1767471841456-migration.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1767471841456 implements MigrationInterface { + name = 'Migration1767471841456' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying, "name" character varying, "description" character varying, "avatarUrl" character varying, "bannerUrl" character varying, "ename" character varying, "isVerified" boolean NOT NULL DEFAULT false, "isPrivate" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "group" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "description" character varying, "owner" character varying, "charter" text, "isPrivate" boolean NOT NULL DEFAULT false, "visibility" character varying NOT NULL DEFAULT 'public', "ename" character varying, "avatarUrl" character varying, "bannerUrl" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_256aa0fda9b1de1a73ee0b7106b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "signature_containers" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fileId" uuid NOT NULL, "userId" uuid NOT NULL, "fileSigneeId" uuid, "md5Hash" text NOT NULL, "signature" text NOT NULL, "publicKey" text NOT NULL, "message" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_11d098c75e494a23c73f3514328" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "file_signees" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fileId" uuid NOT NULL, "userId" uuid NOT NULL, "invitedBy" uuid NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "invitedAt" TIMESTAMP NOT NULL DEFAULT now(), "signedAt" TIMESTAMP, "declinedAt" TIMESTAMP, CONSTRAINT "PK_d1a81a42bd97653e49d9cd5b9f2" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "mimeType" character varying NOT NULL, "size" bigint NOT NULL, "md5Hash" text NOT NULL, "data" bytea NOT NULL, "ownerId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6c16b9093a142e0e7613b04a3d9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "group_members" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_f5939ee0ad233ad35e03f5c65c1" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_2c840df5db52dc6b4a1b0b69c6" ON "group_members" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_20a555b299f75843aa53ff8b0e" ON "group_members" ("user_id") `); + await queryRunner.query(`CREATE TABLE "group_admins" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_a63ab4ea34529a63cdd55eed88d" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_0ecd81bfecc31d4f804ece20ef" ON "group_admins" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_29bb650b1c5b1639dfb089f39a" ON "group_admins" ("user_id") `); + await queryRunner.query(`CREATE TABLE "group_participants" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_92021b85af6470d6b405e12f312" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e61f897ae7a7df4b56595adaae" ON "group_participants" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_bb1d0ab0d82e0a62fa55b7e841" ON "group_participants" ("user_id") `); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "FK_9effe78087d666930d4f48d839a" FOREIGN KEY ("fileId") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "FK_7fc1823b42014453d35e7591333" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "FK_5632d70248b32f4cc82c6683bd0" FOREIGN KEY ("fileSigneeId") REFERENCES "file_signees"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "file_signees" ADD CONSTRAINT "FK_e9d699ee070a08e3fb5959e0e0d" FOREIGN KEY ("fileId") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "file_signees" ADD CONSTRAINT "FK_d60d292d3c1bb7c497aed89ea90" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "file_signees" ADD CONSTRAINT "FK_8ed26523517eae9d204085d5e6d" FOREIGN KEY ("invitedBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "files" ADD CONSTRAINT "FK_a23484d1055e34d75b25f616792" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "group_members" ADD CONSTRAINT "FK_2c840df5db52dc6b4a1b0b69c6e" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_members" ADD CONSTRAINT "FK_20a555b299f75843aa53ff8b0ee" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_admins" ADD CONSTRAINT "FK_0ecd81bfecc31d4f804ece20efc" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_admins" ADD CONSTRAINT "FK_29bb650b1c5b1639dfb089f39a7" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_participants" ADD CONSTRAINT "FK_e61f897ae7a7df4b56595adaae7" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_participants" ADD CONSTRAINT "FK_bb1d0ab0d82e0a62fa55b7e8411" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "group_participants" DROP CONSTRAINT "FK_bb1d0ab0d82e0a62fa55b7e8411"`); + await queryRunner.query(`ALTER TABLE "group_participants" DROP CONSTRAINT "FK_e61f897ae7a7df4b56595adaae7"`); + await queryRunner.query(`ALTER TABLE "group_admins" DROP CONSTRAINT "FK_29bb650b1c5b1639dfb089f39a7"`); + await queryRunner.query(`ALTER TABLE "group_admins" DROP CONSTRAINT "FK_0ecd81bfecc31d4f804ece20efc"`); + await queryRunner.query(`ALTER TABLE "group_members" DROP CONSTRAINT "FK_20a555b299f75843aa53ff8b0ee"`); + await queryRunner.query(`ALTER TABLE "group_members" DROP CONSTRAINT "FK_2c840df5db52dc6b4a1b0b69c6e"`); + await queryRunner.query(`ALTER TABLE "files" DROP CONSTRAINT "FK_a23484d1055e34d75b25f616792"`); + await queryRunner.query(`ALTER TABLE "file_signees" DROP CONSTRAINT "FK_8ed26523517eae9d204085d5e6d"`); + await queryRunner.query(`ALTER TABLE "file_signees" DROP CONSTRAINT "FK_d60d292d3c1bb7c497aed89ea90"`); + await queryRunner.query(`ALTER TABLE "file_signees" DROP CONSTRAINT "FK_e9d699ee070a08e3fb5959e0e0d"`); + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "FK_5632d70248b32f4cc82c6683bd0"`); + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "FK_7fc1823b42014453d35e7591333"`); + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "FK_9effe78087d666930d4f48d839a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bb1d0ab0d82e0a62fa55b7e841"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e61f897ae7a7df4b56595adaae"`); + await queryRunner.query(`DROP TABLE "group_participants"`); + await queryRunner.query(`DROP INDEX "public"."IDX_29bb650b1c5b1639dfb089f39a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0ecd81bfecc31d4f804ece20ef"`); + await queryRunner.query(`DROP TABLE "group_admins"`); + await queryRunner.query(`DROP INDEX "public"."IDX_20a555b299f75843aa53ff8b0e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2c840df5db52dc6b4a1b0b69c6"`); + await queryRunner.query(`DROP TABLE "group_members"`); + await queryRunner.query(`DROP TABLE "files"`); + await queryRunner.query(`DROP TABLE "file_signees"`); + await queryRunner.query(`DROP TABLE "signature_containers"`); + await queryRunner.query(`DROP TABLE "group"`); + await queryRunner.query(`DROP TABLE "users"`); + } + +} diff --git a/platforms/esigner-api/src/database/migrations/1767522780157-AddMessagesTable.ts b/platforms/esigner-api/src/database/migrations/1767522780157-AddMessagesTable.ts new file mode 100644 index 000000000..5f68839d7 --- /dev/null +++ b/platforms/esigner-api/src/database/migrations/1767522780157-AddMessagesTable.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMessagesTable1767522780157 implements MigrationInterface { + name = 'AddMessagesTable1767522780157' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" text NOT NULL, "isSystemMessage" boolean NOT NULL DEFAULT false, "voteId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, "senderId" uuid, "groupId" uuid, CONSTRAINT "PK_18325f38ae6de43878487eff986" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "messages" ADD CONSTRAINT "FK_2db9cf2b3ca111742793f6c37ce" FOREIGN KEY ("senderId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "messages" ADD CONSTRAINT "FK_438f09ab5b4bbcd27683eac2a5e" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_438f09ab5b4bbcd27683eac2a5e"`); + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_2db9cf2b3ca111742793f6c37ce"`); + await queryRunner.query(`DROP TABLE "messages"`); + } + +} diff --git a/platforms/esigner-api/src/database/migrations/1767526081599-migration.ts b/platforms/esigner-api/src/database/migrations/1767526081599-migration.ts new file mode 100644 index 000000000..b68af599e --- /dev/null +++ b/platforms/esigner-api/src/database/migrations/1767526081599-migration.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1767526081599 implements MigrationInterface { + name = 'Migration1767526081599' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "user_evault_mappings" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "localUserId" character varying NOT NULL, "evaultW3id" character varying NOT NULL, "evaultUri" character varying NOT NULL, "userProfileId" character varying, "userProfileData" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_744ddb4ddca6af2de54773e9213" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "group" ADD "originalMatchParticipants" json`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "group" DROP COLUMN "originalMatchParticipants"`); + await queryRunner.query(`DROP TABLE "user_evault_mappings"`); + } + +} diff --git a/platforms/esigner-api/src/database/migrations/1767530000000-AddDisplayNameAndDescriptionToFiles.ts b/platforms/esigner-api/src/database/migrations/1767530000000-AddDisplayNameAndDescriptionToFiles.ts new file mode 100644 index 000000000..08fec134b --- /dev/null +++ b/platforms/esigner-api/src/database/migrations/1767530000000-AddDisplayNameAndDescriptionToFiles.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddDisplayNameAndDescriptionToFiles1767530000000 implements MigrationInterface { + name = 'AddDisplayNameAndDescriptionToFiles1767530000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "files" ADD "displayName" character varying`); + await queryRunner.query(`ALTER TABLE "files" ADD "description" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "files" DROP COLUMN "description"`); + await queryRunner.query(`ALTER TABLE "files" DROP COLUMN "displayName"`); + } +} + diff --git a/platforms/esigner-api/src/database/migrations/1767601409977-migration.ts b/platforms/esigner-api/src/database/migrations/1767601409977-migration.ts new file mode 100644 index 000000000..ea2fbb808 --- /dev/null +++ b/platforms/esigner-api/src/database/migrations/1767601409977-migration.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1767601409977 implements MigrationInterface { + name = 'Migration1767601409977' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "FK_5632d70248b32f4cc82c6683bd0"`); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "UQ_5632d70248b32f4cc82c6683bd0" UNIQUE ("fileSigneeId")`); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "FK_5632d70248b32f4cc82c6683bd0" FOREIGN KEY ("fileSigneeId") REFERENCES "file_signees"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "FK_5632d70248b32f4cc82c6683bd0"`); + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "UQ_5632d70248b32f4cc82c6683bd0"`); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "FK_5632d70248b32f4cc82c6683bd0" FOREIGN KEY ("fileSigneeId") REFERENCES "file_signees"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/platforms/esigner-api/src/index.ts b/platforms/esigner-api/src/index.ts new file mode 100644 index 000000000..9efbce9a1 --- /dev/null +++ b/platforms/esigner-api/src/index.ts @@ -0,0 +1,112 @@ +import "reflect-metadata"; +import express from "express"; +import cors from "cors"; +import { config } from "dotenv"; +import { AppDataSource } from "./database/data-source"; +import path from "path"; +import { AuthController } from "./controllers/AuthController"; +import { FileController } from "./controllers/FileController"; +import { InvitationController } from "./controllers/InvitationController"; +import { SignatureController } from "./controllers/SignatureController"; +import { UserController } from "./controllers/UserController"; +import { authMiddleware, authGuard } from "./middleware/auth"; +import { WebhookController } from "./controllers/WebhookController"; +import { adapter } from "./web3adapter/watchers/subscriber"; +import { PlatformEVaultService } from "./services/PlatformEVaultService"; + +config({ path: path.resolve(__dirname, "../../../.env") }); + +const app = express(); +const port = process.env.PORT || 3004; + +// Initialize database connection and adapter +AppDataSource.initialize() + .then(async () => { + console.log("Database connection established"); + console.log("Web3 adapter initialized"); + + // Initialize platform eVault for eSigner + try { + const platformService = PlatformEVaultService.getInstance(); + const exists = await platformService.checkPlatformEVaultExists(); + + if (!exists) { + console.log("🔧 Creating platform eVault for eSigner..."); + const result = await platformService.createPlatformEVault(); + console.log(`✅ Platform eVault created successfully: ${result.w3id}`); + } else { + console.log("✅ Platform eVault already exists for eSigner"); + } + } catch (error) { + console.error("❌ Failed to initialize platform eVault:", error); + // Don't exit the process, just log the error + } + }) + .catch((error: any) => { + console.error("Error during initialization:", error); + process.exit(1); + }); + +// Middleware +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "OPTIONS", "PATCH", "DELETE"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Webhook-Signature", + "X-Webhook-Timestamp", + ], + credentials: true, + }), +); +app.use(express.json({ limit: "50mb" })); +app.use(express.urlencoded({ limit: "50mb", extended: true })); + +// Controllers +const authController = new AuthController(); +const fileController = new FileController(); +const invitationController = new InvitationController(); +const signatureController = new SignatureController(); +const userController = new UserController(); +const webhookController = new WebhookController(adapter); + +// Public routes (no auth required) +app.get("/api/auth/offer", authController.getOffer); +app.post("/api/auth", authController.login); +app.get("/api/auth/sessions/:id", authController.sseStream); +app.post("/api/webhook", webhookController.handleWebhook); + +// Protected routes (auth required) +app.use(authMiddleware); + +// File routes +app.post("/api/files", authGuard, fileController.uploadFile); +app.get("/api/files", authGuard, fileController.getFiles); +app.get("/api/files/:id", authGuard, fileController.getFile); +app.patch("/api/files/:id", authGuard, fileController.updateFile); +app.get("/api/files/:id/download", authGuard, fileController.downloadFile); +app.delete("/api/files/:id", authGuard, fileController.deleteFile); +app.get("/api/files/:fileId/signatures", authGuard, fileController.getFileSignatures); + +// User routes +app.get("/api/users", authGuard, userController.currentUser); +app.get("/api/users/search", authGuard, userController.search); + +// Invitation routes +app.post("/api/files/:fileId/invite", authGuard, invitationController.inviteSignees); +app.get("/api/files/:fileId/invitations", authGuard, invitationController.getFileInvitations); +app.get("/api/invitations", authGuard, invitationController.getUserInvitations); +app.post("/api/invitations/:id/decline", authGuard, invitationController.declineInvitation); + +// Signature routes +app.post("/api/signatures/session", authGuard, signatureController.createSigningSession); +app.get("/api/signatures/session/:id", signatureController.getSigningSessionStatus); +app.post("/api/signatures/callback", signatureController.handleSignedPayload); + +// Start server +app.listen(port, () => { + console.log(`eSigner API server running on port ${port}`); +}); + diff --git a/platforms/esigner-api/src/middleware/auth.ts b/platforms/esigner-api/src/middleware/auth.ts new file mode 100644 index 000000000..e7bf81410 --- /dev/null +++ b/platforms/esigner-api/src/middleware/auth.ts @@ -0,0 +1,46 @@ +import { Request, Response, NextFunction } from "express"; +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { verifyToken } from "../utils/jwt"; + +export const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return next(); + } + + const token = authHeader.split(" ")[1]; + const decoded = verifyToken(token) as { userId: string }; + + if (!decoded?.userId) { + return res.status(401).json({ error: "Invalid token" }); + } + + const userRepository = AppDataSource.getRepository(User); + const user = await userRepository.findOneBy({ id: decoded.userId }); + + if (!user) { + return res.status(401).json({ error: "User not found" }); + } + + req.user = user; + next(); + } catch (error) { + console.error("Auth middleware error:", error); + res.status(401).json({ error: "Invalid token" }); + } +}; + +export const authGuard = (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +}; + + diff --git a/platforms/esigner-api/src/services/FileService.ts b/platforms/esigner-api/src/services/FileService.ts new file mode 100644 index 000000000..c0aa15b9b --- /dev/null +++ b/platforms/esigner-api/src/services/FileService.ts @@ -0,0 +1,219 @@ +import { AppDataSource } from "../database/data-source"; +import { File } from "../database/entities/File"; +import { FileSignee } from "../database/entities/FileSignee"; +import { SignatureContainer } from "../database/entities/SignatureContainer"; +import crypto from "crypto"; + +export class FileService { + private fileRepository = AppDataSource.getRepository(File); + private fileSigneeRepository = AppDataSource.getRepository(FileSignee); + private signatureRepository = AppDataSource.getRepository(SignatureContainer); + + async calculateMD5(buffer: Buffer): Promise { + return crypto.createHash('md5').update(buffer).digest('hex'); + } + + async createFile( + name: string, + mimeType: string, + size: number, + data: Buffer, + ownerId: string, + displayName?: string, + description?: string + ): Promise { + const md5Hash = await this.calculateMD5(data); + + const fileData: Partial = { + name, + displayName: displayName || name, // Default to file name if not provided + mimeType, + size, + md5Hash, + data, + ownerId, + }; + + if (description !== undefined) { + fileData.description = description || null; + } + + const file = this.fileRepository.create(fileData); + const savedFile = await this.fileRepository.save(file); + return savedFile; + } + + async getFileById(id: string, userId?: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id }, + relations: ["owner", "signees", "signees.user", "signatures", "signatures.user"], + }); + + if (!file) { + return null; + } + + // Check access: owner or invited signee + if (userId) { + if (file.ownerId === userId) { + return file; + } + + const signee = await this.fileSigneeRepository.findOne({ + where: { fileId: id, userId }, + }); + + if (!signee) { + return null; + } + } + + return file; + } + + async getUserFiles(userId: string): Promise { + // Get files owned by user + const ownedFiles = await this.fileRepository.find({ + where: { ownerId: userId }, + relations: ["owner", "signees", "signees.user", "signatures", "signatures.user"], + order: { createdAt: "DESC" }, + }); + + // Get files where user is invited + const invitedFiles = await this.fileSigneeRepository.find({ + where: { userId }, + relations: ["file", "file.owner", "file.signees", "file.signees.user", "file.signatures", "file.signatures.user"], + }); + + const invitedFileIds = new Set(invitedFiles.map(fs => fs.fileId)); + const allFiles = [...ownedFiles]; + + // Add invited files that aren't already in the list + for (const fileSignee of invitedFiles) { + if (!invitedFileIds.has(fileSignee.fileId) || !ownedFiles.find(f => f.id === fileSignee.fileId)) { + if (fileSignee.file) { + allFiles.push(fileSignee.file); + } + } + } + + return allFiles; + } + + async getDocumentsWithStatus(userId: string) { + const files = await this.getUserFiles(userId); + + // Ensure we have all relations loaded + const filesWithRelations = await Promise.all( + files.map(async (file) => { + // Reload with all necessary relations if not already loaded + if (!file.signees || !file.signatures) { + return await this.fileRepository.findOne({ + where: { id: file.id }, + relations: ["owner", "signees", "signees.user", "signatures", "signatures.user"], + }) || file; + } + return file; + }) + ); + + return filesWithRelations.map(file => { + const totalSignees = file.signees?.length || 0; + const signedCount = file.signees?.filter(s => s.status === 'signed').length || 0; + const pendingCount = file.signees?.filter(s => s.status === 'pending').length || 0; + const declinedCount = file.signees?.filter(s => s.status === 'declined').length || 0; + + // Determine status + let status: 'draft' | 'pending' | 'partially_signed' | 'fully_signed' = 'draft'; + if (totalSignees === 0) { + status = 'draft'; + } else if (signedCount === 0 && pendingCount > 0) { + status = 'pending'; + } else if (signedCount > 0 && signedCount < totalSignees) { + status = 'partially_signed'; + } else if (signedCount === totalSignees && totalSignees > 0) { + status = 'fully_signed'; + } + + return { + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + ownerId: file.ownerId, + owner: file.owner ? { + id: file.owner.id, + name: file.owner.name, + ename: file.owner.ename, + } : null, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + status, + totalSignees, + signedCount, + pendingCount, + declinedCount, + signatures: file.signatures?.map(sig => ({ + id: sig.id, + userId: sig.userId, + user: sig.user ? { + id: sig.user.id, + name: sig.user.name, + ename: sig.user.ename, + avatarUrl: sig.user.avatarUrl, + } : null, + createdAt: sig.createdAt, + })) || [], + }; + }); + } + + async updateFile( + id: string, + userId: string, + displayName?: string, + description?: string + ): Promise { + const file = await this.fileRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!file) { + return null; + } + + if (displayName !== undefined) { + file.displayName = displayName || null; + } + if (description !== undefined) { + file.description = description || null; + } + + return await this.fileRepository.save(file); + } + + async deleteFile(id: string, userId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!file) { + return false; + } + + await this.fileRepository.remove(file); + return true; + } + + async getFileSignatures(fileId: string): Promise { + return await this.signatureRepository.find({ + where: { fileId }, + relations: ["user", "fileSignee"], + order: { createdAt: "ASC" }, + }); + } +} + diff --git a/platforms/esigner-api/src/services/GroupService.ts b/platforms/esigner-api/src/services/GroupService.ts new file mode 100644 index 000000000..31b4eef69 --- /dev/null +++ b/platforms/esigner-api/src/services/GroupService.ts @@ -0,0 +1,253 @@ +import { Repository, In } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Group } from "../database/entities/Group"; +import { User } from "../database/entities/User"; + +export class GroupService { + groupRepository: Repository; + userRepository: Repository; + + constructor() { + this.groupRepository = AppDataSource.getRepository(Group); + this.userRepository = AppDataSource.getRepository(User); + } + + // Group CRUD Operations + async findGroupByMembers(memberIds: string[]): Promise { + if (memberIds.length === 0) { + return null; + } + + const sortedMemberIds = memberIds.sort(); + + // For 2-member groups (DMs), use a precise query that ensures exact match + if (sortedMemberIds.length === 2) { + // Find groups that are private and have exactly these 2 members + const groups = await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .andWhere((qb) => { + // Subquery to find groups where both members are present + const subQuery = qb.subQuery() + .select("gm.group_id") + .from("group_members", "gm") + .where("gm.user_id IN (:...memberIds)", { + memberIds: sortedMemberIds + }) + .groupBy("gm.group_id") + .having("COUNT(DISTINCT gm.user_id) = :memberCount", { memberCount: 2 }) + .getQuery(); + return "group.id IN " + subQuery; + }) + .getMany(); + + // Filter groups that have exactly the same 2 members (no more, no less) + for (const group of groups) { + if (group.members && group.members.length === 2) { + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + return group; + } + } + } + } + + // Fallback: get all private groups and filter in memory + const allPrivateGroups = await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .getMany(); + + // Filter groups that have exactly the same members (order doesn't matter) + for (const group of allPrivateGroups) { + if (!group.members || group.members.length !== sortedMemberIds.length) { + continue; + } + + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + return group; + } + } + + return null; + } + + async getGroupById(id: string): Promise { + return await this.groupRepository.findOne({ + where: { id }, + relations: ["members", "admins", "participants"] + }); + } + + async createGroup( + name: string, + description: string, + owner: string, + adminIds: string[] = [], + memberIds: string[] = [], + charter?: string, + isPrivate: boolean = false, + visibility: "public" | "private" | "restricted" = "public", + avatarUrl?: string, + bannerUrl?: string, + originalMatchParticipants?: string[], + ): Promise { + // For eSigner Chat groups, use a transaction to prevent race conditions + if (isPrivate && (name.startsWith("eSigner Chat") || name.includes("eSigner Chat")) && memberIds.length === 2) { + return await AppDataSource.transaction(async (transactionalEntityManager) => { + // First check by description pattern (idempotency check using :: pattern) + if (description && description.includes("::")) { + const existingByDescription = await transactionalEntityManager.findOne(Group, { + where: { + name: name, + description: description, + isPrivate: true + } + }); + if (existingByDescription) { + console.log(`⚠️ DM already exists with description pattern, returning existing DM: ${existingByDescription.id}`); + return existingByDescription; + } + } + + // Check again within transaction to prevent race conditions + const sortedMemberIds = memberIds.sort(); + const existingGroups = await transactionalEntityManager + .createQueryBuilder(Group, "group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .andWhere((qb) => { + const subQuery = qb.subQuery() + .select("gm.group_id") + .from("group_members", "gm") + .where("gm.user_id IN (:...memberIds)", { + memberIds: sortedMemberIds + }) + .groupBy("gm.group_id") + .having("COUNT(DISTINCT gm.user_id) = :memberCount", { memberCount: 2 }) + .getQuery(); + return "group.id IN " + subQuery; + }) + .getMany(); + + // Check if any group has exactly these 2 members + for (const group of existingGroups) { + if (group.members && group.members.length === 2) { + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + console.log(`⚠️ DM already exists between users ${memberIds.join(", ")}, returning existing DM: ${group.id}`); + return group; + } + } + } + + // No existing group found, create new one + const members = await transactionalEntityManager.findBy(User, { + id: In(memberIds), + }); + if (members.length !== memberIds.length) { + throw new Error("One or more members not found"); + } + + const admins = await transactionalEntityManager.findBy(User, { + id: In(adminIds), + }); + if (admins.length !== adminIds.length) { + throw new Error("One or more admins not found"); + } + + const group = transactionalEntityManager.create(Group, { + name, + description, + owner, + charter, + members, + admins, + participants: members, + isPrivate, + visibility, + avatarUrl, + bannerUrl, + originalMatchParticipants: originalMatchParticipants || [], + }); + return await transactionalEntityManager.save(Group, group); + }); + } + + // For non-DM groups, proceed normally + const members = await this.userRepository.findBy({ + id: In(memberIds), + }); + if (members.length !== memberIds.length) { + throw new Error("One or more members not found"); + } + + const admins = await this.userRepository.findBy({ + id: In(adminIds), + }); + if (admins.length !== adminIds.length) { + throw new Error("One or more admins not found"); + } + + const group = this.groupRepository.create({ + name, + description, + owner, + charter, + members, + admins, + participants: members, // Also set participants for compatibility + isPrivate, + visibility, + avatarUrl, + bannerUrl, + originalMatchParticipants: originalMatchParticipants || [], + }); + return await this.groupRepository.save(group); + } + + async updateGroup(id: string, updateData: Partial): Promise { + await this.groupRepository.update(id, updateData); + const updatedGroup = await this.groupRepository.findOneBy({ id }); + if (!updatedGroup) { + throw new Error("Group not found after update"); + } + return updatedGroup; + } + + async getUserGroups(userId: string): Promise { + return await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .leftJoinAndSelect("group.admins", "admins") + .leftJoinAndSelect("group.participants", "participants") + .where("members.id = :userId OR admins.id = :userId OR participants.id = :userId", { userId }) + .getMany(); + } + + async searchGroups(query: string, limit: number = 10): Promise { + return await this.groupRepository + .createQueryBuilder("group") + .where("group.name ILIKE :query OR group.description ILIKE :query", { query: `%${query}%` }) + .limit(limit) + .getMany(); + } + + async isGroupAdmin(groupId: string, userId: string): Promise { + const group = await this.groupRepository.findOne({ + where: { id: groupId }, + relations: ["admins"] + }); + if (!group) return false; + return group.admins.some(admin => admin.id === userId); + } +} + diff --git a/platforms/esigner-api/src/services/InvitationService.ts b/platforms/esigner-api/src/services/InvitationService.ts new file mode 100644 index 000000000..759d131cb --- /dev/null +++ b/platforms/esigner-api/src/services/InvitationService.ts @@ -0,0 +1,173 @@ +import { AppDataSource } from "../database/data-source"; +import { File } from "../database/entities/File"; +import { FileSignee } from "../database/entities/FileSignee"; +import { User } from "../database/entities/User"; +import { In } from "typeorm"; +import { NotificationService } from "./NotificationService"; + +export class InvitationService { + private fileRepository = AppDataSource.getRepository(File); + private fileSigneeRepository = AppDataSource.getRepository(FileSignee); + private userRepository = AppDataSource.getRepository(User); + private notificationService = new NotificationService(); + + async inviteSignees( + fileId: string, + userIds: string[], + invitedBy: string + ): Promise { + // Verify file exists and user is owner + const file = await this.fileRepository.findOne({ + where: { id: fileId, ownerId: invitedBy }, + }); + + if (!file) { + throw new Error("File not found or user is not the owner"); + } + + // Filter out the owner from userIds (they can't invite themselves) + const filteredUserIds = userIds.filter(userId => userId !== invitedBy); + + // Verify all users exist (only if there are users to invite) + if (filteredUserIds.length > 0) { + const users = await this.userRepository.find({ + where: { id: In(filteredUserIds) } + }); + if (users.length !== filteredUserIds.length) { + throw new Error("One or more users not found"); + } + } + + // Create invitations + const invitations: FileSignee[] = []; + + // Always automatically add owner as a signee (for self-signed documents) + const ownerExisting = await this.fileSigneeRepository.findOne({ + where: { fileId, userId: invitedBy }, + }); + + if (!ownerExisting) { + const ownerInvitation = this.fileSigneeRepository.create({ + fileId, + userId: invitedBy, + invitedBy, + status: "pending", + }); + invitations.push(await this.fileSigneeRepository.save(ownerInvitation)); + } + + // Get inviter user for notification + const inviter = await this.userRepository.findOne({ where: { id: invitedBy } }); + const inviterName = inviter?.name || inviter?.ename; + + // Then add invited users (excluding owner) + for (const userId of filteredUserIds) { + // Check if invitation already exists + const existing = await this.fileSigneeRepository.findOne({ + where: { fileId, userId }, + }); + + if (!existing) { + const invitation = this.fileSigneeRepository.create({ + fileId, + userId, + invitedBy, + status: "pending", + }); + const savedInvitation = await this.fileSigneeRepository.save(invitation); + invitations.push(savedInvitation); + + // Send notification to invited user (fire-and-forget) + this.notificationService.sendInvitationNotification(userId, file, inviterName).catch(error => { + console.error(`Failed to send invitation notification to user ${userId}:`, error); + }); + } + } + + return invitations; + } + + async getFileInvitations(fileId: string, userId: string): Promise { + // Verify file exists + const file = await this.fileRepository.findOne({ + where: { id: fileId }, + }); + + if (!file) { + throw new Error("File not found"); + } + + // Check if user is owner or invited signee + const isOwner = file.ownerId === userId; + const isInvited = await this.fileSigneeRepository.findOne({ + where: { fileId, userId }, + }); + + if (!isOwner && !isInvited) { + throw new Error("File not found or user is not authorized"); + } + + return await this.fileSigneeRepository.find({ + where: { fileId }, + relations: ["user", "signature"], + order: { invitedAt: "DESC" }, + }); + } + + async getUserInvitations(userId: string): Promise { + return await this.fileSigneeRepository.find({ + where: { userId, status: "pending" }, + relations: ["file", "file.owner"], + order: { invitedAt: "DESC" }, + }); + } + + async declineInvitation(invitationId: string, userId: string): Promise { + const invitation = await this.fileSigneeRepository.findOne({ + where: { id: invitationId, userId }, + }); + + if (!invitation) { + return false; + } + + if (invitation.status !== "pending") { + throw new Error("Invitation is not pending"); + } + + invitation.status = "declined"; + invitation.declinedAt = new Date(); + await this.fileSigneeRepository.save(invitation); + return true; + } + + async getPendingInvitation(fileId: string, userId: string): Promise { + return await this.fileSigneeRepository.findOne({ + where: { fileId, userId, status: "pending" }, + }); + } + + async updateInvitationStatus( + fileId: string, + userId: string, + status: "signed" | "declined" + ): Promise { + const invitation = await this.fileSigneeRepository.findOne({ + where: { fileId, userId }, + }); + + if (!invitation) { + throw new Error("Invitation not found"); + } + + invitation.status = status; + if (status === "signed") { + invitation.signedAt = new Date(); + } else { + invitation.declinedAt = new Date(); + } + + await this.fileSigneeRepository.save(invitation); + } +} + diff --git a/platforms/esigner-api/src/services/MessageService.ts b/platforms/esigner-api/src/services/MessageService.ts new file mode 100644 index 000000000..cc9a8301c --- /dev/null +++ b/platforms/esigner-api/src/services/MessageService.ts @@ -0,0 +1,135 @@ +import { AppDataSource } from "../database/data-source"; +import { Message } from "../database/entities/Message"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; + +export class MessageService { + public messageRepository = AppDataSource.getRepository(Message); + private userRepository = AppDataSource.getRepository(User); + private groupRepository = AppDataSource.getRepository(Group); + + async createMessage(messageData: { + text: string; + senderId: string; + groupId: string; + }): Promise { + const sender = await this.userRepository.findOne({ where: { id: messageData.senderId } }); + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!sender || !group) { + throw new Error("Sender or group not found"); + } + + const message = this.messageRepository.create({ + text: messageData.text, + sender, + group, + isSystemMessage: false, + }); + + return await this.messageRepository.save(message); + } + + async createSystemMessage(messageData: { + text: string; + groupId: string; + }): Promise { + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!group) { + throw new Error("Group not found"); + } + + // Add the system message prefix for web3-adapter compatibility + const prefixedText = `$$system-message$$ ${messageData.text}`; + + const message = this.messageRepository.create({ + text: prefixedText, + sender: undefined, // Use undefined instead of null for optional field + group, + isSystemMessage: true, + }); + + return await this.messageRepository.save(message); + } + + async createSystemMessageWithoutPrefix(messageData: { + text: string; + groupId: string; + }): Promise { + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!group) { + throw new Error("Group not found"); + } + + const message = this.messageRepository.create({ + text: messageData.text, + sender: undefined, // Use undefined instead of null for optional field + group, + isSystemMessage: true, + }); + + return await this.messageRepository.save(message); + } + + async getMessageById(id: string): Promise { + return await this.messageRepository.findOne({ + where: { id }, + relations: ['sender', 'group'] + }); + } + + async getGroupMessages(groupId: string): Promise { + return await this.messageRepository.find({ + where: { group: { id: groupId } }, + relations: ['sender', 'group'], + order: { createdAt: 'ASC' } + }); + } + + async updateMessage(id: string, messageData: Partial): Promise { + // Get the current message, merge the data, and save it to trigger ORM events + const currentMessage = await this.getMessageById(id); + if (!currentMessage) { + throw new Error("Message not found"); + } + + // Merge the new data with the existing message + Object.assign(currentMessage, messageData); + + // Save the merged message to trigger ORM subscribers + const updatedMessage = await this.messageRepository.save(currentMessage); + return updatedMessage; + } + + async deleteMessage(id: string): Promise { + const result = await this.messageRepository.delete(id); + return result.affected ? result.affected > 0 : false; + } + + async getUserMessages(userId: string): Promise { + const messages = await this.messageRepository.find({ + where: { sender: { id: userId } }, + relations: ['sender', 'group'], + order: { createdAt: 'DESC' } + }); + + return messages; + } + + async archiveMessage(id: string): Promise { + // Get the current message, set archived flag, and save it to trigger ORM events + const currentMessage = await this.getMessageById(id); + if (!currentMessage) { + throw new Error("Message not found"); + } + + currentMessage.isArchived = true; + + // Save the updated message to trigger ORM subscribers + const archivedMessage = await this.messageRepository.save(currentMessage); + return archivedMessage; + } +} + diff --git a/platforms/esigner-api/src/services/NotificationService.ts b/platforms/esigner-api/src/services/NotificationService.ts new file mode 100644 index 000000000..f746faba1 --- /dev/null +++ b/platforms/esigner-api/src/services/NotificationService.ts @@ -0,0 +1,366 @@ +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; +import { Message } from "../database/entities/Message"; +import { File } from "../database/entities/File"; +import { FileSignee } from "../database/entities/FileSignee"; +import { UserService } from "./UserService"; +import { GroupService } from "./GroupService"; +import { MessageService } from "./MessageService"; + +export class NotificationService { + private userService: UserService; + private groupService: GroupService; + private messageService: MessageService; + private esignerUser: User | null = null; + + constructor() { + this.userService = new UserService(); + this.groupService = new GroupService(); + this.messageService = new MessageService(); + } + + /** + * Find the eSigner platform user by searching for "eSigner Platform" in their name + */ + public async findESignerUser(): Promise { + if (this.esignerUser) { + return this.esignerUser; + } + + try { + // Search for users with "eSigner Platform" in their name + const users = await this.userService.searchUsers("eSigner Platform"); + this.esignerUser = users.find(user => + user.name?.includes("eSigner Platform") + ) || null; + + if (!this.esignerUser) { + console.error("❌ eSigner platform user not found in database"); + } else { + console.log(`✅ Found eSigner platform user: ${this.esignerUser.id}`); + } + + return this.esignerUser; + } catch (error) { + console.error("Error finding eSigner user:", error); + return null; + } + } + + /** + * Find or create a mutual chat between eSigner user and another user + * Returns both the chat and whether it was just created + */ + async findOrCreateMutualChat(targetUserId: string): Promise<{ chat: Group | null; wasCreated: boolean }> { + console.log(`🔍 Looking for mutual chat between eSigner and user: ${targetUserId}`); + + const esignerUser = await this.findESignerUser(); + if (!esignerUser) { + console.error("❌ Cannot create mutual chat: eSigner user not found"); + return { chat: null, wasCreated: false }; + } + + console.log(`👤 eSigner user found: ${esignerUser.id} (${esignerUser.name || esignerUser.ename})`); + + try { + // Check if a mutual chat already exists between these two users + console.log(`🔍 Checking for existing mutual chat between eSigner (${esignerUser.id}) and user (${targetUserId})`); + + const existingChat = await this.groupService.findGroupByMembers([ + esignerUser.id, + targetUserId + ]); + + if (existingChat) { + console.log(`✅ Found existing mutual chat: ${existingChat.id}`); + console.log(`📋 Chat details: Name="${existingChat.name}", Private=${existingChat.isPrivate}, Members=${existingChat.members?.length || 0}`); + return { chat: existingChat, wasCreated: false }; + } + + console.log(`🆕 No existing mutual chat found, creating new one...`); + + // Create a new mutual chat + const chatName = `eSigner Chat with ${targetUserId}`; + const chatDescription = `DM ID: ${targetUserId}::${esignerUser.id}`; + + console.log(`🔧 Creating mutual chat with:`); + console.log(` - Name: ${chatName}`); + console.log(` - Description: ${chatDescription}`); + console.log(` - Owner: ${esignerUser.id}`); + console.log(` - Members: [${esignerUser.id}, ${targetUserId}]`); + console.log(` - Private: true`); + + const mutualChat = await this.groupService.createGroup( + chatName, + chatDescription, + esignerUser.id, // eSigner is the owner + [esignerUser.id], // eSigner is admin + [esignerUser.id, targetUserId], // Both users are participants + undefined, // No charter + true, // isPrivate + "private", // visibility + undefined, // avatarUrl + undefined, // bannerUrl + [] // originalMatchParticipants + ); + + // Double-check: if createGroup returned an existing chat (due to race condition), verify it's the right one + if (mutualChat.id) { + const verifyChat = await this.groupService.findGroupByMembers([ + esignerUser.id, + targetUserId + ]); + + if (verifyChat && verifyChat.id !== mutualChat.id) { + console.log(`⚠️ Race condition detected: found different chat ${verifyChat.id}, using it instead`); + return { chat: verifyChat, wasCreated: false }; + } + } + + console.log(`✅ Created new mutual chat: ${mutualChat.id}`); + console.log(`📋 New chat details: Name="${mutualChat.name}", Private=${mutualChat.isPrivate}, Members=${mutualChat.members?.length || 0}`); + return { chat: mutualChat, wasCreated: true }; + } catch (error) { + console.error("❌ Error creating mutual chat:", error); + return { chat: null, wasCreated: false }; + } + } + + /** + * Send signing invitation notification to a user + */ + async sendInvitationNotification(userId: string, file: File, inviterName?: string): Promise { + try { + const esignerUser = await this.findESignerUser(); + if (!esignerUser) { + console.error("❌ Cannot send notification: eSigner user not found"); + return; + } + + // Find or create mutual chat + const chatResult = await this.findOrCreateMutualChat(userId); + if (!chatResult.chat) { + console.error(`❌ Cannot send notification: failed to create chat for user ${userId}`); + return; + } + + const mutualChat = chatResult.chat; + const wasCreated = chatResult.wasCreated; + + // If chat was just created, wait 5 seconds before sending message + if (wasCreated) { + console.log(`⏳ Chat was just created, waiting 5 seconds before sending message...`); + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log(`✅ 5-second delay completed for invitation message`); + } + + // Generate the invitation message + const messageContent = this.generateInvitationMessage(file, inviterName); + + console.log(`💾 Creating invitation notification message...`); + const message = await this.messageService.createSystemMessage({ + text: messageContent, + groupId: mutualChat.id, + }); + + console.log(`✅ Message saved with ID: ${message.id}`); + console.log(`✅ Invitation notification sent to user ${userId} in chat ${mutualChat.id}`); + } catch (error) { + console.error(`❌ Error sending invitation notification to user ${userId}:`, error); + console.error(`❌ Error details:`, (error as Error).message); + console.error(`❌ Error stack:`, (error as Error).stack); + } + } + + /** + * Send signature completion notification to a user + */ + async sendSignatureNotification(userId: string, file: File, signerName?: string): Promise { + try { + const esignerUser = await this.findESignerUser(); + if (!esignerUser) { + console.error("❌ Cannot send notification: eSigner user not found"); + return; + } + + // Find or create mutual chat + const chatResult = await this.findOrCreateMutualChat(userId); + if (!chatResult.chat) { + console.error(`❌ Cannot send notification: failed to create chat for user ${userId}`); + return; + } + + const mutualChat = chatResult.chat; + const wasCreated = chatResult.wasCreated; + + // If chat was just created, wait 5 seconds before sending message + if (wasCreated) { + console.log(`⏳ Chat was just created, waiting 5 seconds before sending message...`); + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log(`✅ 5-second delay completed for signature message`); + } + + // Generate the signature message + const messageContent = this.generateSignatureMessage(file, signerName); + + console.log(`💾 Creating signature notification message...`); + const message = await this.messageService.createSystemMessage({ + text: messageContent, + groupId: mutualChat.id, + }); + + console.log(`✅ Message saved with ID: ${message.id}`); + console.log(`✅ Signature notification sent to user ${userId} in chat ${mutualChat.id}`); + } catch (error) { + console.error(`❌ Error sending signature notification to user ${userId}:`, error); + console.error(`❌ Error details:`, (error as Error).message); + console.error(`❌ Error stack:`, (error as Error).stack); + } + } + + /** + * Send fully signed notification to all signees + */ + async sendFullySignedNotification(file: File, signeeIds: string[]): Promise { + try { + const esignerUser = await this.findESignerUser(); + if (!esignerUser) { + console.error("❌ Cannot send notification: eSigner user not found"); + return; + } + + // Send notification to all signees + for (const userId of signeeIds) { + try { + // Find or create mutual chat + const chatResult = await this.findOrCreateMutualChat(userId); + if (!chatResult.chat) { + console.error(`❌ Cannot send notification: failed to create chat for user ${userId}`); + continue; + } + + const mutualChat = chatResult.chat; + const wasCreated = chatResult.wasCreated; + + // If chat was just created, wait 5 seconds before sending message + if (wasCreated) { + console.log(`⏳ Chat was just created, waiting 5 seconds before sending message...`); + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log(`✅ 5-second delay completed for fully signed message`); + } + + // Generate the fully signed message + const messageContent = this.generateFullySignedMessage(file); + + console.log(`💾 Creating fully signed notification message...`); + const message = await this.messageService.createSystemMessage({ + text: messageContent, + groupId: mutualChat.id, + }); + + console.log(`✅ Message saved with ID: ${message.id}`); + console.log(`✅ Fully signed notification sent to user ${userId} in chat ${mutualChat.id}`); + } catch (error) { + console.error(`❌ Error sending fully signed notification to user ${userId}:`, error); + } + } + } catch (error) { + console.error(`❌ Error sending fully signed notifications:`, error); + } + } + + /** + * Generate invitation message content + */ + private generateInvitationMessage(file: File, inviterName?: string): string { + const formattedTime = new Date().toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + const inviterText = inviterName ? ` from ${inviterName}` : ''; + const containerName = file.displayName || file.name; + const descriptionText = file.description ? `\nDescription: ${file.description}` : ''; + + return `📝 Signature Invitation + +You have been invited${inviterText} to sign a signature container. + +Signature Container: ${containerName}${descriptionText} +File: ${file.name} +Size: ${this.formatFileSize(file.size)} +Type: ${file.mimeType} +Time: ${formattedTime} + +Please review and sign the signature container when ready.`; + } + + /** + * Generate signature message content + */ + private generateSignatureMessage(file: File, signerName?: string): string { + const formattedTime = new Date().toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + const signerText = signerName ? ` by ${signerName}` : ''; + const containerName = file.displayName || file.name; + const descriptionText = file.description ? `\nDescription: ${file.description}` : ''; + + return `✅ Signature Completed + +A signature container has been signed${signerText}. + +Signature Container: ${containerName}${descriptionText} +File: ${file.name} +Time: ${formattedTime} + +The signature has been recorded and verified.`; + } + + /** + * Generate fully signed message content + */ + private generateFullySignedMessage(file: File): string { + const formattedTime = new Date().toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + const containerName = file.displayName || file.name; + const descriptionText = file.description ? `\nDescription: ${file.description}` : ''; + + return `🎉 Signature Container Fully Signed + +All parties have signed the signature container. + +Signature Container: ${containerName}${descriptionText} +File: ${file.name} +Time: ${formattedTime} + +The signature container is now complete. You can download the proof from the eSigner platform.`; + } + + /** + * Format file size + */ + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + diff --git a/platforms/esigner-api/src/services/PlatformEVaultService.ts b/platforms/esigner-api/src/services/PlatformEVaultService.ts new file mode 100644 index 000000000..9d1b013ad --- /dev/null +++ b/platforms/esigner-api/src/services/PlatformEVaultService.ts @@ -0,0 +1,321 @@ +import axios from "axios"; +import { GraphQLClient } from "graphql-request"; +import { v4 as uuidv4 } from "uuid"; +import { UserEVaultMapping } from "../database/entities/UserEVaultMapping"; +import { AppDataSource } from "../database/data-source"; + +const STORE_META_ENVELOPE = ` + mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { + storeMetaEnvelope(input: $input) { + metaEnvelope { + id + ontology + parsed + } + } + } +`; + +interface MetaEnvelopeResponse { + storeMetaEnvelope: { + metaEnvelope: { + id: string; + ontology: string; + parsed: any; + }; + }; +} + +interface PlatformProfile { + platformName: string; + displayName: string; + description: string; + version: string; + ename: string; + isActive: boolean; + createdAt: string; + updatedAt: string; + isArchived: boolean; +} + +export class PlatformEVaultService { + private static instance: PlatformEVaultService; + private client: GraphQLClient | null = null; + private endpoint: string | null = null; + private w3id: string | null = null; + + private constructor() {} + + public static getInstance(): PlatformEVaultService { + if (!PlatformEVaultService.instance) { + PlatformEVaultService.instance = new PlatformEVaultService(); + } + return PlatformEVaultService.instance; + } + + /** + * Check if eSigner platform eVault already exists + */ + async checkPlatformEVaultExists(): Promise { + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + const existingMapping = await mappingRepository.findOne({ + where: { localUserId: "esigner-platform" }, + }); + return !!existingMapping; + } + + /** + * Create eVault for eSigner platform (one-time setup) + */ + async createPlatformEVault(): Promise<{ + w3id: string; + uri: string; + userProfileId: string; + }> { + console.log("Creating platform eVault for eSigner..."); + + // Check if platform eVault already exists + const exists = await this.checkPlatformEVaultExists(); + if (exists) { + throw new Error("Platform eVault already exists for eSigner"); + } + + try { + // Step 1: Get entropy from registry + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:3000"; + const { + data: { token: registryEntropy }, + } = await axios.get(new URL("/entropy", registryUrl).toString()); + + // Step 2: Provision eVault + const provisionerUrl = + process.env.PUBLIC_PROVISIONER_URL || "http://localhost:3001"; + const verificationId = + process.env.DEMO_VERIFICATION_CODE || + "d66b7138-538a-465f-a6ce-f6985854c3f4"; + + const { data } = await axios.post( + new URL("/provision", provisionerUrl).toString(), + { + registryEntropy, + namespace: uuidv4(), + verificationId, + publicKey: "0x00000000000000000000000000000000000000", + }, + ); + + if (!data || data.success !== true) { + throw new Error("Failed to provision platform eVault"); + } + + const { w3id, uri } = data; + + // Step 3: Create PlatformProfile in eVault + const userProfileId = await this.createPlatformProfileInEVault( + w3id, + uri, + ); + + // Step 4: Store mapping in database + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + const mapping = new UserEVaultMapping(); + mapping.localUserId = "esigner-platform"; + mapping.evaultW3id = w3id; + mapping.evaultUri = uri; + mapping.userProfileId = userProfileId; + mapping.userProfileData = { + platformName: "esigner", + displayName: "eSigner Platform", + description: + "eSigner - Digital signature and document signing platform", + version: "1.0.0", + }; + + await mappingRepository.save(mapping); + + console.log("Platform eVault created successfully:", { + w3id, + uri, + userProfileId, + }); + + return { w3id, uri, userProfileId }; + } catch (error) { + console.error("Failed to create platform eVault:", error); + throw error; + } + } + + /** + * Resolve eVault endpoint from registry + */ + private async resolveEndpoint(w3id: string): Promise { + try { + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:3000"; + const response = await axios.get( + new URL(`resolve?w3id=${w3id}`, registryUrl).toString(), + ); + return new URL("/graphql", response.data.uri).toString(); + } catch (error) { + console.error("Error resolving eVault endpoint:", error); + throw new Error("Failed to resolve eVault endpoint"); + } + } + + /** + * Ensure we have a valid GraphQL client + */ + private async ensureClient(w3id: string): Promise { + // Recreate client if w3id changed or client/endpoint is missing + if (!this.endpoint || !this.client || this.w3id !== w3id) { + this.endpoint = await this.resolveEndpoint(w3id); + this.client = new GraphQLClient(this.endpoint, { + headers: { + "X-ENAME": w3id, + }, + }); + this.w3id = w3id; + } + return this.client; + } + + /** + * Create PlatformProfile in eVault with retry mechanism + */ + private async createPlatformProfileInEVault( + w3id: string, + uri: string, + maxRetries = 20, + ): Promise { + console.log("Creating PlatformProfile in eVault..."); + + const now = new Date().toISOString(); + const platformProfile: PlatformProfile = { + platformName: "esigner", + displayName: "eSigner Platform", + description: + "eSigner - Digital signature and document signing platform", + version: "1.0.0", + ename: w3id, + isActive: true, + createdAt: now, + updatedAt: now, + isArchived: false, + }; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const client = await this.ensureClient(w3id); + + console.log( + `Attempting to create PlatformProfile in eVault (attempt ${attempt}/${maxRetries})`, + ); + + const response = await client.request( + STORE_META_ENVELOPE, + { + input: { + ontology: "550e8400-e29b-41d4-a716-446655440000", // UserProfile ontology + payload: platformProfile, + acl: ["*"], + }, + }, + ); + + const userProfileId = + response.storeMetaEnvelope.metaEnvelope.id; + console.log( + "PlatformProfile created successfully in eVault:", + userProfileId, + ); + return userProfileId; + } catch (error) { + console.error( + `Failed to create PlatformProfile in eVault (attempt ${attempt}/${maxRetries}):`, + error, + ); + + if (attempt === maxRetries) { + console.error( + "Max retries reached, giving up on PlatformProfile creation", + ); + throw error; + } + + // Wait before retrying (exponential backoff) + const delay = Math.min(1000 * 2 ** (attempt - 1), 20000); + console.log(`Waiting ${delay}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error("Failed to create PlatformProfile after all retries"); + } + + /** + * Get platform eVault mapping + */ + async getPlatformEVaultMapping(): Promise { + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + return await mappingRepository.findOne({ + where: { localUserId: "esigner-platform" }, + }); + } + + /** + * Get platform eName (W3ID) + */ + async getPlatformEName(): Promise { + const mapping = await this.getPlatformEVaultMapping(); + return mapping?.evaultW3id || null; + } + + /** + * Get platform eVault URI + */ + async getPlatformEVaultUri(): Promise { + const mapping = await this.getPlatformEVaultMapping(); + return mapping?.evaultUri || null; + } + + /** + * Update platform profile in eVault + */ + async updatePlatformProfile( + updates: Partial, + ): Promise { + const mapping = await this.getPlatformEVaultMapping(); + if (!mapping) { + throw new Error("Platform eVault mapping not found"); + } + + const client = await this.ensureClient(mapping.evaultW3id); + + // Get current profile data + const currentData = mapping.userProfileData as PlatformProfile; + const updatedData = { + ...currentData, + ...updates, + updatedAt: new Date().toISOString(), + }; + + // Update in eVault + await client.request(STORE_META_ENVELOPE, { + input: { + ontology: "550e8400-e29b-41d4-a716-446655440000", + payload: updatedData, + acl: ["*"], + }, + }); + + // Update local mapping + mapping.userProfileData = updatedData; + await AppDataSource.getRepository(UserEVaultMapping).save(mapping); + } +} + diff --git a/platforms/esigner-api/src/services/SignatureService.ts b/platforms/esigner-api/src/services/SignatureService.ts new file mode 100644 index 000000000..5ce0bc2b7 --- /dev/null +++ b/platforms/esigner-api/src/services/SignatureService.ts @@ -0,0 +1,376 @@ +import crypto from "crypto"; +import { AppDataSource } from "../database/data-source"; +import { File } from "../database/entities/File"; +import { SignatureContainer } from "../database/entities/SignatureContainer"; +import { FileSignee } from "../database/entities/FileSignee"; +import { InvitationService } from "./InvitationService"; +import { UserService } from "./UserService"; +import { NotificationService } from "./NotificationService"; +import { verifySignature } from "signature-validator"; + +export interface SigningSession { + sessionId: string; + fileId: string; + userId: string; + md5Hash: string; + qrData: string; + createdAt: Date; + expiresAt: Date; + status: "pending" | "signed" | "expired" | "completed" | "security_violation"; +} + +export interface SigningResult { + success: boolean; + error?: string; + sessionId: string; + fileId: string; + userId: string; + signature?: string; + publicKey?: string; + message?: string; + type: "signed" | "security_violation"; +} + +export class SignatureService { + private sessions: Map = new Map(); // Keyed by sessionId (userId_md5) + private signatureRepository = AppDataSource.getRepository(SignatureContainer); + private fileRepository = AppDataSource.getRepository(File); + private fileSigneeRepository = AppDataSource.getRepository(FileSignee); + private invitationService = new InvitationService(); + private userService = new UserService(); + private notificationService = new NotificationService(); + + async createSession(fileId: string, userId: string): Promise { + // Verify user has pending invitation + const invitation = await this.invitationService.getPendingInvitation(fileId, userId); + if (!invitation) { + throw new Error("No pending invitation found for this file"); + } + + // Get file to retrieve MD5 hash + const file = await this.fileRepository.findOne({ + where: { id: fileId }, + }); + + if (!file) { + throw new Error("File not found"); + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes + + // Create session ID as userId_md5 so wallet signs both assertions: + // 1. The user ID (who is signing) + // 2. The MD5 hash (what file they're signing) + const sessionId = `${userId}_${file.md5Hash}`; + + // Create signature request data with MD5 hash + const messageData = JSON.stringify({ + message: `Sign file: ${file.name}`, + md5Hash: file.md5Hash, + sessionId: sessionId + }); + + const base64Data = Buffer.from(messageData).toString('base64'); + const apiBaseUrl = process.env.PUBLIC_ESIGNER_BASE_URL || "http://localhost:3004"; + const redirectUri = `${apiBaseUrl}/api/signatures/callback`; + + // Put userId_md5 as the session parameter so wallet signs both assertions + const qrData = `w3ds://sign?session=${encodeURIComponent(sessionId)}&data=${base64Data}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + const session: SigningSession = { + sessionId, + fileId, + userId, + md5Hash: file.md5Hash, + qrData, + createdAt: now, + expiresAt, + status: "pending" + }; + + this.sessions.set(sessionId, session); + console.log(`Created signing session ${sessionId} for file ${fileId}, total sessions: ${this.sessions.size}`); + + // Set up expiration cleanup + setTimeout(() => { + const session = this.sessions.get(sessionId); + if (session && session.status === "pending") { + session.status = "expired"; + this.sessions.set(sessionId, session); + } + }, 15 * 60 * 1000); + + return session; + } + + async getSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + + if (!session) { + return null; + } + + // Check if session has expired + if (session.status === "pending" && new Date() > session.expiresAt) { + session.status = "expired"; + this.sessions.set(sessionId, session); + } + + return session; + } + + async processSignedPayload( + sessionId: string, + signature: string, + publicKey: string, + message: string + ): Promise { + console.log(`Processing signed payload. Received sessionId: ${sessionId}, message: ${message}`); + + // The wallet signs the sessionId which is userId_md5 format + // So sessionId and message should both be userId_md5 + // Find session by sessionId + const session = await this.getSession(sessionId); + + if (!session) { + throw new Error("Session not found"); + } + + if (session.status !== "pending") { + throw new Error("Session is not in pending state"); + } + + if (!signature || !publicKey || !message) { + throw new Error("Invalid signature data"); + } + + // Verify signature using signature-validator + const registryBaseUrl = process.env.PUBLIC_REGISTRY_URL; + if (!registryBaseUrl) { + throw new Error("PUBLIC_REGISTRY_URL not configured"); + } + + // Get user to verify ename + const user = await this.userService.getUserById(session.userId); + if (!user) { + throw new Error("User not found for session"); + } + + // The wallet signs the sessionId which is userId_md5 format + // The message field from the wallet should contain userId_md5 that was signed + // Verify signature against the sessionId (userId_md5) + const payloadToVerify = message || sessionId; // The message should be userId_md5 that was signed + console.log(`Verifying signature. Payload (signed): ${payloadToVerify}, Expected: ${session.sessionId}`); + + // Verify the payload matches the expected sessionId format (userId_md5) + const expectedPayload = `${session.userId}_${session.md5Hash}`; + if (payloadToVerify !== expectedPayload && payloadToVerify !== session.sessionId) { + console.error(`🔒 SECURITY VIOLATION: payload mismatch!`, { + receivedPayload: payloadToVerify, + expectedPayload: expectedPayload, + sessionId: session.sessionId, + }); + session.status = "security_violation"; + this.sessions.set(session.sessionId, session); + return { + success: false, + error: "Signed payload does not match expected format", + sessionId: session.sessionId, + fileId: session.fileId, + userId: session.userId, + type: "security_violation" + }; + } + + // Verify signature against the sessionId (userId_md5) + const verificationResult = await verifySignature({ + eName: user.ename, + signature: signature, + payload: payloadToVerify, // Verify against userId_md5 that was signed + registryBaseUrl: registryBaseUrl, + }); + + if (!verificationResult.valid) { + console.error("Signature validation failed:", verificationResult.error); + session.status = "security_violation"; + this.sessions.set(session.sessionId, session); + return { + success: false, + error: verificationResult.error || "Invalid signature", + sessionId: session.sessionId, + fileId: session.fileId, + userId: session.userId, + type: "security_violation" + }; + } + + // Extract userId and md5Hash from the signed payload to verify + const payloadParts = payloadToVerify.split('_'); + if (payloadParts.length < 2) { + console.error(`🔒 SECURITY VIOLATION: invalid payload format!`, { + payload: payloadToVerify, + }); + session.status = "security_violation"; + this.sessions.set(session.sessionId, session); + return { + success: false, + error: "Invalid payload format", + sessionId: session.sessionId, + fileId: session.fileId, + userId: session.userId, + type: "security_violation" + }; + } + + const signedUserId = payloadParts[0]; + const signedMd5Hash = payloadParts.slice(1).join('_'); // In case md5 hash contains underscores + + // Verify the signed userId matches the session userId + if (signedUserId !== session.userId) { + console.error(`🔒 SECURITY VIOLATION: userId mismatch!`, { + signedUserId, + expectedUserId: session.userId, + }); + session.status = "security_violation"; + this.sessions.set(session.sessionId, session); + return { + success: false, + error: "Signed user ID does not match session user ID", + sessionId: session.sessionId, + fileId: session.fileId, + userId: session.userId, + type: "security_violation" + }; + } + + // Verify the signed MD5 hash matches the file's MD5 hash + if (signedMd5Hash !== session.md5Hash) { + console.error(`🔒 SECURITY VIOLATION: MD5 hash mismatch!`, { + signedMd5Hash, + expectedMd5Hash: session.md5Hash, + }); + session.status = "security_violation"; + this.sessions.set(session.sessionId, session); + return { + success: false, + error: "Signed MD5 hash does not match file hash", + sessionId: session.sessionId, + fileId: session.fileId, + userId: session.userId, + type: "security_violation" + }; + } + + // Verify publicKey matches user's ename + const cleanPublicKey = publicKey.replace(/^@/, ''); + const cleanUserEname = user.ename.replace(/^@/, ''); + + if (cleanPublicKey !== cleanUserEname) { + console.error(`🔒 SECURITY VIOLATION: publicKey mismatch!`, { + publicKey, + userEname: user.ename, + cleanPublicKey, + cleanUserEname, + }); + + session.status = "security_violation"; + this.sessions.set(sessionId, session); + + return { + success: false, + error: "Public key does not match the user who created this signing session", + sessionId, + fileId: session.fileId, + userId: session.userId, + type: "security_violation" + }; + } + + + // Get invitation to link signature + const invitation = await this.invitationService.getPendingInvitation(session.fileId, session.userId); + if (!invitation) { + throw new Error("Invitation not found"); + } + + // Store signature in database + try { + const signatureContainer = this.signatureRepository.create({ + fileId: session.fileId, + userId: session.userId, + fileSigneeId: invitation.id, + md5Hash: session.md5Hash, + signature, + publicKey, + message: payloadToVerify, // Store the userId_md5 that was signed + }); + + await this.signatureRepository.save(signatureContainer); + + // Update invitation status + await this.invitationService.updateInvitationStatus( + session.fileId, + session.userId, + "signed" + ); + + console.log(`✅ Signature recorded for file ${session.fileId} by user ${session.userId}`); + + // Get file and user for notifications (fire-and-forget) + this.fileRepository.findOne({ where: { id: session.fileId } }).then(async (file) => { + if (!file) return; + + const signer = await this.userService.getUserById(session.userId); + const signerName = signer?.name || signer?.ename; + + // Send notification to the owner (fire-and-forget) + this.notificationService.sendSignatureNotification(file.ownerId, file, signerName).catch(error => { + console.error(`Failed to send signature notification to owner:`, error); + }); + + // Check if all parties have signed + const allSignees = await this.fileSigneeRepository.find({ + where: { fileId: session.fileId }, + }); + + const allSigned = allSignees.every(signee => signee.status === "signed"); + + if (allSigned && allSignees.length > 0) { + // Send fully signed notification to all signees (fire-and-forget) + const signeeIds = allSignees.map(s => s.userId); + this.notificationService.sendFullySignedNotification(file, signeeIds).catch(error => { + console.error(`Failed to send fully signed notifications:`, error); + }); + } + }).catch(error => { + console.error(`Failed to process notifications:`, error); + }); + } catch (error) { + console.error("Failed to record signature:", error); + throw new Error("Failed to record signature"); + } + + // Update session status + session.status = "completed"; + this.sessions.set(sessionId, session); + + const result: SigningResult = { + success: true, + sessionId, + fileId: session.fileId, + userId: session.userId, + signature, + publicKey, + message, + type: "signed" + }; + + return result; + } + + async getSessionStatus(sessionId: string): Promise { + return this.getSession(sessionId); + } +} + diff --git a/platforms/esigner-api/src/services/UserService.ts b/platforms/esigner-api/src/services/UserService.ts new file mode 100644 index 000000000..566995cdf --- /dev/null +++ b/platforms/esigner-api/src/services/UserService.ts @@ -0,0 +1,143 @@ +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { signToken } from "../utils/jwt"; + +export class UserService { + userRepository = AppDataSource.getRepository(User); + + async createBlankUser(ename: string): Promise { + const user = this.userRepository.create({ + ename, + isVerified: false, + isPrivate: false, + isArchived: false, + }); + + return await this.userRepository.save(user); + } + + async findOrCreateUser( + ename: string + ): Promise<{ user: User; token: string }> { + let user = await this.userRepository.findOne({ + where: { ename }, + }); + + if (!user) { + user = await this.createBlankUser(ename); + } + + const token = signToken({ userId: user.id }); + return { user, token }; + } + + async findById(id: string): Promise { + return await this.userRepository.findOneBy({ id }); + } + + async findByEname(ename: string): Promise { + const normalizedEname = ename.startsWith('@') ? ename.slice(1) : ename; + const enameWithAt = `@${normalizedEname}`; + + const user = await this.userRepository + .createQueryBuilder("user") + .where("user.ename = :enameWithAt OR user.ename = :enameWithoutAt", { + enameWithAt, + enameWithoutAt: normalizedEname, + }) + .getOne(); + + return user; + } + + async findUser(ename: string): Promise { + return this.findByEname(ename); + } + + async getUserById(id: string): Promise { + return await this.findById(id); + } + + searchUsers = async ( + query: string, + page: number = 1, + limit: number = 10, + verifiedOnly: boolean = false, + sortBy: string = "relevance" + ) => { + const searchQuery = query.trim(); + + if (searchQuery.length < 2) { + return []; + } + + if (page < 1 || limit < 1 || limit > 100) { + return []; + } + + const queryBuilder = this.userRepository + .createQueryBuilder("user") + .select([ + "user.id", + "user.handle", + "user.name", + "user.ename", + "user.description", + "user.avatarUrl", + "user.isVerified" + ]) + .addSelect(` + CASE + WHEN user.ename ILIKE :exactQuery THEN 100 + WHEN user.name ILIKE :exactQuery THEN 90 + WHEN user.handle ILIKE :exactQuery THEN 80 + WHEN user.ename ILIKE :query THEN 70 + WHEN user.name ILIKE :query THEN 60 + WHEN user.handle ILIKE :query THEN 50 + WHEN user.description ILIKE :query THEN 30 + WHEN user.ename ILIKE :fuzzyQuery THEN 40 + WHEN user.name ILIKE :fuzzyQuery THEN 35 + WHEN user.handle ILIKE :fuzzyQuery THEN 30 + ELSE 0 + END`, 'relevance_score') + .where( + "user.name ILIKE :query OR user.ename ILIKE :query OR user.handle ILIKE :query OR user.description ILIKE :query OR user.ename ILIKE :fuzzyQuery OR user.name ILIKE :fuzzyQuery OR user.handle ILIKE :fuzzyQuery", + { + query: `%${searchQuery}%`, + exactQuery: searchQuery, + fuzzyQuery: `%${searchQuery.split('').join('%')}%` + } + ); + + if (verifiedOnly) { + queryBuilder.andWhere("user.isVerified = :verified", { verified: true }); + } + + queryBuilder.andWhere("user.isArchived = :archived", { archived: false }); + + switch (sortBy) { + case "name": + queryBuilder.orderBy("user.name", "ASC"); + break; + case "verified": + queryBuilder.orderBy("user.isVerified", "DESC").addOrderBy("user.name", "ASC"); + break; + case "newest": + queryBuilder.orderBy("user.createdAt", "DESC"); + break; + case "relevance": + default: + queryBuilder.orderBy("relevance_score", "DESC") + .addOrderBy("user.isVerified", "DESC") + .addOrderBy("user.name", "ASC"); + break; + } + + return queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + }; +} + + diff --git a/platforms/esigner-api/src/types/express.d.ts b/platforms/esigner-api/src/types/express.d.ts new file mode 100644 index 000000000..8236625e5 --- /dev/null +++ b/platforms/esigner-api/src/types/express.d.ts @@ -0,0 +1,11 @@ +import { User } from "../database/entities/User"; + +declare global { + namespace Express { + interface Request { + user?: User; + } + } +} + + diff --git a/platforms/esigner-api/src/utils/jwt.ts b/platforms/esigner-api/src/utils/jwt.ts new file mode 100644 index 000000000..0ea065ba2 --- /dev/null +++ b/platforms/esigner-api/src/utils/jwt.ts @@ -0,0 +1,17 @@ +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export const signToken = (payload: any): string => { + return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' }); +}; + +export const verifyToken = (token: string): any => { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + throw new Error('Invalid token'); + } +}; + + diff --git a/platforms/esigner-api/src/utils/version.ts b/platforms/esigner-api/src/utils/version.ts new file mode 100644 index 000000000..256771a1f --- /dev/null +++ b/platforms/esigner-api/src/utils/version.ts @@ -0,0 +1,32 @@ +/** + * Compares two semantic version strings + * @param version1 - First version string (e.g., "0.4.0") + * @param version2 - Second version string (e.g., "0.3.0") + * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2 + */ +export function compareVersions(version1: string, version2: string): number { + const v1Parts = version1.split('.').map(Number); + const v2Parts = version2.split('.').map(Number); + + for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { + const v1Part = v1Parts[i] || 0; + const v2Part = v2Parts[i] || 0; + + if (v1Part < v2Part) return -1; + if (v1Part > v2Part) return 1; + } + + return 0; +} + +/** + * Checks if the app version meets the minimum required version + * @param appVersion - The version from the app (e.g., "0.4.0") + * @param minVersion - The minimum required version (e.g., "0.4.0") + * @returns true if appVersion >= minVersion, false otherwise + */ +export function isVersionValid(appVersion: string, minVersion: string): boolean { + return compareVersions(appVersion, minVersion) >= 0; +} + + diff --git a/platforms/esigner-api/src/web3adapter/mappings/group.mapping.json b/platforms/esigner-api/src/web3adapter/mappings/group.mapping.json new file mode 100644 index 000000000..d786648b7 --- /dev/null +++ b/platforms/esigner-api/src/web3adapter/mappings/group.mapping.json @@ -0,0 +1,26 @@ +{ + "tableName": "groups", + "schemaId": "550e8400-e29b-41d4-a716-446655440003", + "ownerEnamePath": "users(participants[].ename)", + "ownedJunctionTables": [ + "group_participants" + ], + "localToUniversalMap": { + "name": "name", + "description": "description", + "owner": "owner", + "admins": "users(admins[].id),adminIds", + "charter": "charter", + "ename": "ename", + "participants": "users(participants[].id),participantIds", + "members": "users(members[].id),memberIds", + "originalMatchParticipants": "originalMatchParticipants", + "isPrivate": "isPrivate", + "visibility": "visibility", + "avatarUrl": "avatarUrl", + "bannerUrl": "bannerUrl", + "createdAt": "createdAt", + "updatedAt": "updatedAt" + }, + "readOnly": false +} \ No newline at end of file diff --git a/platforms/esigner-api/src/web3adapter/mappings/message.mapping.json b/platforms/esigner-api/src/web3adapter/mappings/message.mapping.json new file mode 100644 index 000000000..6c5705475 --- /dev/null +++ b/platforms/esigner-api/src/web3adapter/mappings/message.mapping.json @@ -0,0 +1,15 @@ +{ + "tableName": "messages", + "schemaId": "550e8400-e29b-41d4-a716-446655440004", + "ownerEnamePath": "groups(group.ename)||users(group.members[].ename)", + "ownedJunctionTables": [], + "localToUniversalMap": { + "text": "content", + "sender": "users(sender.id),senderId", + "group": "groups(group.id),chatId", + "isSystemMessage": "isSystemMessage", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived" + } +} \ No newline at end of file diff --git a/platforms/esigner-api/src/web3adapter/mappings/user.mapping.json b/platforms/esigner-api/src/web3adapter/mappings/user.mapping.json new file mode 100644 index 000000000..91156bb47 --- /dev/null +++ b/platforms/esigner-api/src/web3adapter/mappings/user.mapping.json @@ -0,0 +1,20 @@ +{ + "tableName": "users", + "readOnly": true, + "schemaId": "550e8400-e29b-41d4-a716-446655440000", + "ownerEnamePath": "ename", + "ownedJunctionTables": [], + "localToUniversalMap": { + "handle": "username", + "name": "name", + "description": "bio", + "avatarUrl": "avatarUrl", + "bannerUrl": "bannerUrl", + "ename": "ename", + "isVerified": "isVerified", + "isPrivate": "isPrivate", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived" + } +} \ No newline at end of file diff --git a/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts b/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts new file mode 100644 index 000000000..ce9dbe269 --- /dev/null +++ b/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts @@ -0,0 +1,270 @@ +import { + EventSubscriber, + EntitySubscriberInterface, + InsertEvent, + UpdateEvent, + RemoveEvent, + ObjectLiteral, +} from "typeorm"; +import { Web3Adapter } from "web3-adapter"; +import path from "path"; +import dotenv from "dotenv"; +import { AppDataSource } from "../../database/data-source"; + +dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") }); +export const adapter = new Web3Adapter({ + schemasPath: path.resolve(__dirname, "../mappings/"), + dbPath: path.resolve(process.env.ESIGNER_MAPPING_DB_PATH as string), + registryUrl: process.env.PUBLIC_REGISTRY_URL as string, + platform: process.env.PUBLIC_ESIGNER_BASE_URL as string, +}); + +@EventSubscriber() +export class PostgresSubscriber implements EntitySubscriberInterface { + private adapter: Web3Adapter; + private pendingChanges: Map = new Map(); + + constructor() { + this.adapter = adapter; + + setInterval(() => { + this.cleanupOldPendingChanges(); + }, 5 * 60 * 1000); + } + + private cleanupOldPendingChanges(): void { + const now = Date.now(); + const maxAge = 10 * 60 * 1000; + + for (const [key, timestamp] of this.pendingChanges.entries()) { + if (now - timestamp > maxAge) { + this.pendingChanges.delete(key); + } + } + } + + async enrichEntity(entity: any, tableName: string, tableTarget: any) { + try { + const enrichedEntity = { ...entity }; + return this.entityToPlain(enrichedEntity); + } catch (error) { + console.error("Error loading relations:", error); + return this.entityToPlain(entity); + } + } + + /** + * Special enrichment method for Message entities to ensure group and admin data is loaded + */ + private async enrichMessageEntity(messageEntity: any): Promise { + try { + const enrichedMessage = { ...messageEntity }; + + // If the message has a group, load the full group with admins and members + if (enrichedMessage.group && enrichedMessage.group.id) { + const groupRepository = AppDataSource.getRepository("Group"); + const fullGroup = await groupRepository.findOne({ + where: { id: enrichedMessage.group.id }, + relations: ["admins", "members", "participants"] + }); + + if (fullGroup) { + enrichedMessage.group = fullGroup; + } + } + + // If the message has a sender, ensure it's loaded + if (enrichedMessage.sender && enrichedMessage.sender.id) { + const userRepository = AppDataSource.getRepository("User"); + const fullSender = await userRepository.findOne({ + where: { id: enrichedMessage.sender.id } + }); + + if (fullSender) { + enrichedMessage.sender = fullSender; + } + } + + return enrichedMessage; + } catch (error) { + console.error("Error enriching Message entity:", error); + return messageEntity; + } + } + + async afterInsert(event: InsertEvent) { + let entity = event.entity; + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + + // Special handling for Message entities to ensure complete data + if (event.metadata.tableName === "messages" && entity) { + entity = await this.enrichMessageEntity(entity); + } + + this.handleChange( + entity ?? event.entityId, + event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s" + ); + } + + async afterUpdate(event: UpdateEvent) { + // For updates, we need to reload the full entity since event.entity only contains changed fields + let entity = event.entity; + + // Try different ways to get the entity ID + let entityId = event.entity?.id || event.databaseEntity?.id; + + if (!entityId && event.entity) { + // If we have the entity but no ID, try to extract it from the entity object + const entityKeys = Object.keys(event.entity); + + // Look for common ID field names + entityId = event.entity.id || event.entity.Id || event.entity.ID || event.entity._id; + } + + if (entityId) { + // Reload the full entity from the database + const repository = AppDataSource.getRepository(event.metadata.target); + + // Determine relations based on entity type + let relations: string[] = []; + if (event.metadata.tableName === "messages") { + relations = ["sender", "group", "group.members", "group.admins", "group.participants"]; + } else if (event.metadata.tableName === "groups") { + relations = ["members", "admins", "participants"]; + } + + const fullEntity = await repository.findOne({ + where: { id: entityId }, + relations: relations.length > 0 ? relations : undefined + }); + + if (fullEntity) { + entity = (await this.enrichEntity( + fullEntity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + } + + // Special handling for Message entities to ensure complete data + if (event.metadata.tableName === "messages" && entity) { + entity = await this.enrichMessageEntity(entity); + } + + this.handleChange( + entity ?? event.entity, + event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s" + ); + } + + async afterRemove(event: RemoveEvent) { + let entity = event.entity; + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + this.handleChange( + entity ?? event.entityId, + event.metadata.tableName + ); + } + + private async handleChange(entity: any, tableName: string): Promise { + // Handle users, groups, and messages + if (tableName !== "users" && tableName !== "groups" && tableName !== "messages") { + return; + } + + const data = this.entityToPlain(entity); + if (!data.id) return; + + const changeKey = `${tableName}:${entity.id}`; + + if (this.pendingChanges.has(changeKey)) { + return; + } + + this.pendingChanges.set(changeKey, Date.now()); + + try { + setTimeout(async () => { + try { + let globalId = await this.adapter.mappingDb.getGlobalId( + entity.id + ); + globalId = globalId ?? ""; + + if (this.adapter.lockedIds.includes(globalId)) { + return; + } + + // Check if this entity was recently created by a webhook + if (this.adapter.lockedIds.includes(entity.id)) { + return; + } + + const envelope = await this.adapter.handleChange({ + data, + tableName: tableName.toLowerCase(), + }); + } finally { + this.pendingChanges.delete(changeKey); + } + }, 3_000); + } catch (error) { + console.error(`Error processing change for ${tableName}:`, error); + this.pendingChanges.delete(changeKey); + } + } + + private entityToPlain(entity: any): any { + if (!entity) return {}; + + if (typeof entity !== "object" || entity === null) { + return entity; + } + + if (entity instanceof Date) { + return entity.toISOString(); + } + + if (Array.isArray(entity)) { + return entity.map((item) => this.entityToPlain(item)); + } + + const plain: Record = {}; + for (const [key, value] of Object.entries(entity)) { + if (key.startsWith("_")) continue; + + if (value && typeof value === "object") { + if (Array.isArray(value)) { + plain[key] = value.map((item) => this.entityToPlain(item)); + } else if (value instanceof Date) { + plain[key] = value.toISOString(); + } else { + plain[key] = this.entityToPlain(value); + } + } else { + plain[key] = value; + } + } + + return plain; + } +} + diff --git a/platforms/esigner-api/tsconfig.json b/platforms/esigner-api/tsconfig.json new file mode 100644 index 000000000..9c0614563 --- /dev/null +++ b/platforms/esigner-api/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "moduleResolution": "node", + "baseUrl": "./src", + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "typeRoots": [ + "./src/types", + "./node_modules/@types" + ] + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["node_modules", "dist"] +} + + diff --git a/platforms/esigner/.svelte-kit/ambient.d.ts b/platforms/esigner/.svelte-kit/ambient.d.ts new file mode 100644 index 000000000..b8199036d --- /dev/null +++ b/platforms/esigner/.svelte-kit/ambient.d.ts @@ -0,0 +1,258 @@ + +// this file is generated — do not edit it + + +/// + +/** + * Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured). + * + * _Unlike_ [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * ```ts + * import { API_KEY } from '$env/static/private'; + * ``` + * + * Note that all environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * + * ``` + * MY_FEATURE_FLAG="" + * ``` + * + * You can override `.env` values from the command line like so: + * + * ```sh + * MY_FEATURE_FLAG="enabled" npm run dev + * ``` + */ +declare module '$env/static/private' { + export const NEO4J_URI: string; + export const NEO4J_USER: string; + export const NEO4J_PASSWORD: string; + export const REGISTRY_ENTROPY_KEY_JWK: string; + export const ENCRYPTION_PASSWORD: string; + export const W3ID: string; + export const REGISTRY_DATABASE_URL: string; + export const REGISTRY_SHARED_SECRET: string; + export const PROVISIONER_DATABASE_URL: string; + export const VERIFF_HMAC_KEY: string; + export const DUPLICATES_POLICY: string; + export const IP_ADDR: string; + export const PICTIQUE_DATABASE_URL: string; + export const PICTIQUE_MAPPING_DB_PATH: string; + export const BLABSY_MAPPING_DB_PATH: string; + export const DREAMSYNC_MAPPING_DB_PATH: string; + export const GROUP_CHARTER_MAPPING_DB_PATH: string; + export const CERBERUS_MAPPING_DB_PATH: string; + export const GOOGLE_APPLICATION_CREDENTIALS: string; + export const GROUP_CHARTER_DATABASE_URL: string; + export const CERBERUS_DATABASE_URL: string; + export const EVOTING_DATABASE_URL: string; + export const EVOTING_MAPPING_DB_PATH: string; + export const OPENAI_API_KEY: string; + export const NOTIFICATION_SHARED_SECRET: string; + export const DREAMSYNC_DATABASE_URL: string; + export const VITE_DREAMSYNC_BASE_URL: string; + export const ECURRENCY_DATABASE_URL: string; + export const ECURRENCY_MAPPING_DB_PATH: string; + export const VITE_ECURRENCY_BASE_URL: string; + export const JWT_SECRET: string; + export const EREPUTATION_DATABASE_URL: string; + export const EREPUTATION_MAPPING_DB_PATH: string; + export const VITE_EREPUTATION_BASE_URL: string; + export const ESIGNER_DATABASE_URL: string; + export const ESIGNER_MAPPING_DB_PATH: string; + export const LOAD_TEST_USER_COUNT: string; + export const SHELL: string; + export const npm_command: string; + export const COLORTERM: string; + export const npm_config_optional: string; + export const npm_config_npm_globalconfig: string; + export const NODE: string; + export const npm_config_verify_deps_before_run: string; + export const npm_config__jsr_registry: string; + export const npm_config_strict_peer_dependencies: string; + export const npm_config_globalconfig: string; + export const PWD: string; + export const HOME: string; + export const LANG: string; + export const npm_package_version: string; + export const TURBO_IS_TUI: string; + export const pnpm_config_verify_deps_before_run: string; + export const INIT_CWD: string; + export const npm_lifecycle_script: string; + export const TURBO_HASH: string; + export const TERM: string; + export const npm_package_name: string; + export const USER: string; + export const npm_config_frozen_lockfile: string; + export const DISPLAY: string; + export const npm_lifecycle_event: string; + export const SHLVL: string; + export const npm_config_user_agent: string; + export const PNPM_SCRIPT_SRC_DIR: string; + export const npm_execpath: string; + export const XDG_RUNTIME_DIR: string; + export const NODE_PATH: string; + export const npm_package_json: string; + export const PATH: string; + export const npm_config_node_gyp: string; + export const DBUS_SESSION_BUS_ADDRESS: string; + export const npm_config_registry: string; + export const npm_node_execpath: string; + export const TERM_PROGRAM: string; + export const NODE_ENV: string; +} + +/** + * Similar to [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private), except that it only includes environment variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Values are replaced statically at build time. + * + * ```ts + * import { PUBLIC_BASE_URL } from '$env/static/public'; + * ``` + */ +declare module '$env/static/public' { + export const PUBLIC_EVAULT_SERVER_URI: string; + export const PUBLIC_VERIFF_KEY: string; + export const PUBLIC_REGISTRY_URL: string; + export const PUBLIC_PROVISIONER_URL: string; + export const PUBLIC_PICTIQUE_URL: string; + export const PUBLIC_PICTIQUE_BASE_URL: string; + export const PUBLIC_BLABSY_URL: string; + export const PUBLIC_BLABSY_BASE_URL: string; + export const PUBLIC_GROUP_CHARTER_BASE_URL: string; + export const PUBLIC_CERBERUS_BASE_URL: string; + export const PUBLIC_EVOTING_BASE_URL: string; + export const PUBLIC_EVOTING_URL: string; + export const PUBLIC_APP_STORE_EID_WALLET: string; + export const PUBLIC_PLAY_STORE_EID_WALLET: string; + export const PUBLIC_ESIGNER_BASE_URL: string; +} + +/** + * This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured). + * + * This module cannot be imported into client-side code. + * + * ```ts + * import { env } from '$env/dynamic/private'; + * console.log(env.DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + * + * > [!NOTE] In `dev`, `$env/dynamic` always includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + */ +declare module '$env/dynamic/private' { + export const env: { + NEO4J_URI: string; + NEO4J_USER: string; + NEO4J_PASSWORD: string; + REGISTRY_ENTROPY_KEY_JWK: string; + ENCRYPTION_PASSWORD: string; + W3ID: string; + REGISTRY_DATABASE_URL: string; + REGISTRY_SHARED_SECRET: string; + PROVISIONER_DATABASE_URL: string; + VERIFF_HMAC_KEY: string; + DUPLICATES_POLICY: string; + IP_ADDR: string; + PICTIQUE_DATABASE_URL: string; + PICTIQUE_MAPPING_DB_PATH: string; + BLABSY_MAPPING_DB_PATH: string; + DREAMSYNC_MAPPING_DB_PATH: string; + GROUP_CHARTER_MAPPING_DB_PATH: string; + CERBERUS_MAPPING_DB_PATH: string; + GOOGLE_APPLICATION_CREDENTIALS: string; + GROUP_CHARTER_DATABASE_URL: string; + CERBERUS_DATABASE_URL: string; + EVOTING_DATABASE_URL: string; + EVOTING_MAPPING_DB_PATH: string; + OPENAI_API_KEY: string; + NOTIFICATION_SHARED_SECRET: string; + DREAMSYNC_DATABASE_URL: string; + VITE_DREAMSYNC_BASE_URL: string; + ECURRENCY_DATABASE_URL: string; + ECURRENCY_MAPPING_DB_PATH: string; + VITE_ECURRENCY_BASE_URL: string; + JWT_SECRET: string; + EREPUTATION_DATABASE_URL: string; + EREPUTATION_MAPPING_DB_PATH: string; + VITE_EREPUTATION_BASE_URL: string; + ESIGNER_DATABASE_URL: string; + ESIGNER_MAPPING_DB_PATH: string; + LOAD_TEST_USER_COUNT: string; + SHELL: string; + npm_command: string; + COLORTERM: string; + npm_config_optional: string; + npm_config_npm_globalconfig: string; + NODE: string; + npm_config_verify_deps_before_run: string; + npm_config__jsr_registry: string; + npm_config_strict_peer_dependencies: string; + npm_config_globalconfig: string; + PWD: string; + HOME: string; + LANG: string; + npm_package_version: string; + TURBO_IS_TUI: string; + pnpm_config_verify_deps_before_run: string; + INIT_CWD: string; + npm_lifecycle_script: string; + TURBO_HASH: string; + TERM: string; + npm_package_name: string; + USER: string; + npm_config_frozen_lockfile: string; + DISPLAY: string; + npm_lifecycle_event: string; + SHLVL: string; + npm_config_user_agent: string; + PNPM_SCRIPT_SRC_DIR: string; + npm_execpath: string; + XDG_RUNTIME_DIR: string; + NODE_PATH: string; + npm_package_json: string; + PATH: string; + npm_config_node_gyp: string; + DBUS_SESSION_BUS_ADDRESS: string; + npm_config_registry: string; + npm_node_execpath: string; + TERM_PROGRAM: string; + NODE_ENV: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * Similar to [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), but only includes variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Note that public dynamic environment variables must all be sent from the server to the client, causing larger network requests — when possible, use `$env/static/public` instead. + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.PUBLIC_DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + PUBLIC_EVAULT_SERVER_URI: string; + PUBLIC_VERIFF_KEY: string; + PUBLIC_REGISTRY_URL: string; + PUBLIC_PROVISIONER_URL: string; + PUBLIC_PICTIQUE_URL: string; + PUBLIC_PICTIQUE_BASE_URL: string; + PUBLIC_BLABSY_URL: string; + PUBLIC_BLABSY_BASE_URL: string; + PUBLIC_GROUP_CHARTER_BASE_URL: string; + PUBLIC_CERBERUS_BASE_URL: string; + PUBLIC_EVOTING_BASE_URL: string; + PUBLIC_EVOTING_URL: string; + PUBLIC_APP_STORE_EID_WALLET: string; + PUBLIC_PLAY_STORE_EID_WALLET: string; + PUBLIC_ESIGNER_BASE_URL: string; + [key: `PUBLIC_${string}`]: string | undefined; + } +} diff --git a/platforms/esigner/.svelte-kit/generated/client/app.js b/platforms/esigner/.svelte-kit/generated/client/app.js new file mode 100644 index 000000000..b03d8aada --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/app.js @@ -0,0 +1,38 @@ +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1'), + () => import('./nodes/2'), + () => import('./nodes/3'), + () => import('./nodes/4'), + () => import('./nodes/5'), + () => import('./nodes/6'), + () => import('./nodes/7') +]; + +export const server_loads = []; + +export const dictionary = { + "/": [3], + "/(auth)/auth": [4], + "/(protected)/files": [5,[2]], + "/(protected)/files/new": [7,[2]], + "/(protected)/files/[id]": [6,[2]] + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), + + reroute: (() => {}), + transport: {} +}; + +export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); +export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode])); + +export const hash = false; + +export const decode = (type, value) => decoders[type](value); + +export { default as root } from '../root.js'; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/matchers.js b/platforms/esigner/.svelte-kit/generated/client/matchers.js new file mode 100644 index 000000000..f6bd30a4e --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/matchers.js @@ -0,0 +1 @@ +export const matchers = {}; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/0.js b/platforms/esigner/.svelte-kit/generated/client/nodes/0.js new file mode 100644 index 000000000..fed1375f7 --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/0.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+layout.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/1.js b/platforms/esigner/.svelte-kit/generated/client/nodes/1.js new file mode 100644 index 000000000..635016f9c --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/1.js @@ -0,0 +1 @@ +export { default as component } from "../../../../../../node_modules/.pnpm/@sveltejs+kit@2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@5.1.1_svelte_f2289347040f8edd51fabc9e5c8ef0b5/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/2.js b/platforms/esigner/.svelte-kit/generated/client/nodes/2.js new file mode 100644 index 000000000..e899b5cfd --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/2.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(protected)/+layout.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/3.js b/platforms/esigner/.svelte-kit/generated/client/nodes/3.js new file mode 100644 index 000000000..1cb4f8552 --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/3.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+page.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/4.js b/platforms/esigner/.svelte-kit/generated/client/nodes/4.js new file mode 100644 index 000000000..773b48116 --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/4.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(auth)/auth/+page.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/5.js b/platforms/esigner/.svelte-kit/generated/client/nodes/5.js new file mode 100644 index 000000000..5362e6dbe --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/5.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(protected)/files/+page.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/6.js b/platforms/esigner/.svelte-kit/generated/client/nodes/6.js new file mode 100644 index 000000000..8bd5dab8a --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/6.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(protected)/files/[id]/+page.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/client/nodes/7.js b/platforms/esigner/.svelte-kit/generated/client/nodes/7.js new file mode 100644 index 000000000..86a2cd32a --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/client/nodes/7.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(protected)/files/new/+page.svelte"; \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/root.js b/platforms/esigner/.svelte-kit/generated/root.js new file mode 100644 index 000000000..4d1e8929f --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/root.js @@ -0,0 +1,3 @@ +import { asClassComponent } from 'svelte/legacy'; +import Root from './root.svelte'; +export default asClassComponent(Root); \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/root.svelte b/platforms/esigner/.svelte-kit/generated/root.svelte new file mode 100644 index 000000000..1c16fc867 --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/root.svelte @@ -0,0 +1,80 @@ + + + + +{#if constructors[1]} + {@const Pyramid_0 = constructors[0]} + + + {#if constructors[2]} + {@const Pyramid_1 = constructors[1]} + + + + + + + {:else} + {@const Pyramid_1 = constructors[1]} + + + + {/if} + + +{:else} + {@const Pyramid_0 = constructors[0]} + + + +{/if} + +{#if mounted} +
+ {#if navigated} + {title} + {/if} +
+{/if} \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/generated/server/internal.js b/platforms/esigner/.svelte-kit/generated/server/internal.js new file mode 100644 index 000000000..eeb5af337 --- /dev/null +++ b/platforms/esigner/.svelte-kit/generated/server/internal.js @@ -0,0 +1,53 @@ + +import root from '../root.js'; +import { set_building, set_prerendering } from '__sveltekit/environment'; +import { set_assets } from '$app/paths/internal/server'; +import { set_manifest, set_read_implementation } from '__sveltekit/server'; +import { set_private_env, set_public_env } from '../../../../../node_modules/.pnpm/@sveltejs+kit@2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@5.1.1_svelte_f2289347040f8edd51fabc9e5c8ef0b5/node_modules/@sveltejs/kit/src/runtime/shared-server.js'; + +export const options = { + app_template_contains_nonce: false, + async: false, + csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, + csrf_check_origin: true, + csrf_trusted_origins: [], + embedded: false, + env_public_prefix: 'PUBLIC_', + env_private_prefix: '', + hash_routing: false, + hooks: null, // added lazily, via `get_hooks` + preload_strategy: "modulepreload", + root, + service_worker: false, + service_worker_options: undefined, + templates: { + app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t
" + body + "
\n\t\n\n\n", + error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" + }, + version_hash: "r5aqr4" +}; + +export async function get_hooks() { + let handle; + let handleFetch; + let handleError; + let handleValidationError; + let init; + + + let reroute; + let transport; + + + return { + handle, + handleFetch, + handleError, + handleValidationError, + init, + reroute, + transport + }; +} + +export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation }; diff --git a/platforms/esigner/.svelte-kit/non-ambient.d.ts b/platforms/esigner/.svelte-kit/non-ambient.d.ts new file mode 100644 index 000000000..2d6d6dde0 --- /dev/null +++ b/platforms/esigner/.svelte-kit/non-ambient.d.ts @@ -0,0 +1,47 @@ + +// this file is generated — do not edit it + + +declare module "svelte/elements" { + export interface HTMLAttributes { + 'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-preload-code'?: + | true + | '' + | 'eager' + | 'viewport' + | 'hover' + | 'tap' + | 'off' + | undefined + | null; + 'data-sveltekit-preload-data'?: true | '' | 'hover' | 'tap' | 'off' | undefined | null; + 'data-sveltekit-reload'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-replacestate'?: true | '' | 'off' | undefined | null; + } +} + +export {}; + + +declare module "$app/types" { + export interface AppTypes { + RouteId(): "/(protected)" | "/(auth)" | "/" | "/(auth)/auth" | "/(protected)/files" | "/(protected)/files/new" | "/(protected)/files/[id]"; + RouteParams(): { + "/(protected)/files/[id]": { id: string } + }; + LayoutParams(): { + "/(protected)": { id?: string }; + "/(auth)": Record; + "/": { id?: string }; + "/(auth)/auth": Record; + "/(protected)/files": { id?: string }; + "/(protected)/files/new": Record; + "/(protected)/files/[id]": { id: string } + }; + Pathname(): "/" | "/auth" | "/auth/" | "/files" | "/files/" | "/files/new" | "/files/new/" | `/files/${string}` & {} | `/files/${string}/` & {}; + ResolvedPathname(): `${"" | `/${string}`}${ReturnType}`; + Asset(): string & {}; + } +} \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/tsconfig.json b/platforms/esigner/.svelte-kit/tsconfig.json new file mode 100644 index 000000000..64aad0734 --- /dev/null +++ b/platforms/esigner/.svelte-kit/tsconfig.json @@ -0,0 +1,52 @@ +{ + "compilerOptions": { + "paths": { + "$lib": [ + "../src/lib" + ], + "$lib/*": [ + "../src/lib/*" + ], + "$app/types": [ + "./types/index.d.ts" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "verbatimModuleSyntax": true, + "isolatedModules": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "bundler", + "module": "esnext", + "noEmit": true, + "target": "esnext" + }, + "include": [ + "ambient.d.ts", + "non-ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "../src/service-worker.js", + "../src/service-worker/**/*.js", + "../src/service-worker.ts", + "../src/service-worker/**/*.ts", + "../src/service-worker.d.ts", + "../src/service-worker/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/types/route_meta_data.json b/platforms/esigner/.svelte-kit/types/route_meta_data.json new file mode 100644 index 000000000..d45ad958e --- /dev/null +++ b/platforms/esigner/.svelte-kit/types/route_meta_data.json @@ -0,0 +1,8 @@ +{ + "/(protected)": [], + "/": [], + "/(auth)/auth": [], + "/(protected)/files": [], + "/(protected)/files/new": [], + "/(protected)/files/[id]": [] +} \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/types/src/routes/$types.d.ts b/platforms/esigner/.svelte-kit/types/src/routes/$types.d.ts new file mode 100644 index 000000000..32f48a484 --- /dev/null +++ b/platforms/esigner/.svelte-kit/types/src/routes/$types.d.ts @@ -0,0 +1,24 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; +type LayoutRouteId = RouteId | "/" | "/(auth)/auth" | "/(protected)/files" | "/(protected)/files/[id]" | "/(protected)/files/new" | null +type LayoutParams = RouteParams & { id?: string } +type LayoutParentData = EnsureDefined<{}>; + +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } +export type LayoutServerData = null; +export type LayoutData = Expand; +export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/types/src/routes/(auth)/auth/$types.d.ts b/platforms/esigner/.svelte-kit/types/src/routes/(auth)/auth/$types.d.ts new file mode 100644 index 000000000..b22693e94 --- /dev/null +++ b/platforms/esigner/.svelte-kit/types/src/routes/(auth)/auth/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/(auth)/auth'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/types/src/routes/(protected)/$types.d.ts b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/$types.d.ts new file mode 100644 index 000000000..c7a34bfa5 --- /dev/null +++ b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/$types.d.ts @@ -0,0 +1,20 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/(protected)'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type LayoutRouteId = RouteId | "/(protected)/files" | "/(protected)/files/[id]" | "/(protected)/files/new" +type LayoutParams = RouteParams & { id?: string } +type LayoutParentData = EnsureDefined; + +export type LayoutServerData = null; +export type LayoutData = Expand; +export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/$types.d.ts b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/$types.d.ts new file mode 100644 index 000000000..9a8701d2d --- /dev/null +++ b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/(protected)/files'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = Omit, keyof import('../$types.js').LayoutData> & EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/[id]/$types.d.ts b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/[id]/$types.d.ts new file mode 100644 index 000000000..70863a49e --- /dev/null +++ b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/[id]/$types.d.ts @@ -0,0 +1,19 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/(protected)/files/[id]'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = Omit, keyof import('../../$types.js').LayoutData> & EnsureDefined; + +export type EntryGenerator = () => Promise> | Array; +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file diff --git a/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/new/$types.d.ts b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/new/$types.d.ts new file mode 100644 index 000000000..3397883c0 --- /dev/null +++ b/platforms/esigner/.svelte-kit/types/src/routes/(protected)/files/new/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/(protected)/files/new'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = Omit, keyof import('../../$types.js').LayoutData> & EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file diff --git a/platforms/esigner/package.json b/platforms/esigner/package.json new file mode 100644 index 000000000..990782087 --- /dev/null +++ b/platforms/esigner/package.json @@ -0,0 +1,46 @@ +{ + "name": "esigner", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "clsx": "^2.1.1", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.7.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^6.2.6" + }, + "dependencies": { + "@sveltejs/adapter-node": "^5.2.12", + "axios": "^1.6.7", + "svelte-qrcode": "^1.0.1", + "svelte-qrcode-action": "^1.0.2", + "tailwind-merge": "^3.0.2" + } +} + diff --git a/platforms/esigner/src/app.css b/platforms/esigner/src/app.css new file mode 100644 index 000000000..ea5d081ba --- /dev/null +++ b/platforms/esigner/src/app.css @@ -0,0 +1,7 @@ +@import 'tailwindcss'; + +body { + font-family: system-ui, -apple-system, sans-serif; + background-color: white; +} + diff --git a/platforms/esigner/src/app.d.ts b/platforms/esigner/src/app.d.ts new file mode 100644 index 000000000..92b602baa --- /dev/null +++ b/platforms/esigner/src/app.d.ts @@ -0,0 +1,14 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +export {}; + + diff --git a/platforms/esigner/src/app.html b/platforms/esigner/src/app.html new file mode 100644 index 000000000..86c2e23aa --- /dev/null +++ b/platforms/esigner/src/app.html @@ -0,0 +1,12 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + diff --git a/platforms/esigner/src/lib/components/ToastContainer.svelte b/platforms/esigner/src/lib/components/ToastContainer.svelte new file mode 100644 index 000000000..97ba62504 --- /dev/null +++ b/platforms/esigner/src/lib/components/ToastContainer.svelte @@ -0,0 +1,83 @@ + + +
+ {#each $toasts as toast} + + {/each} +
+ + + + diff --git a/platforms/esigner/src/lib/components/UserMenuDropdown.svelte b/platforms/esigner/src/lib/components/UserMenuDropdown.svelte new file mode 100644 index 000000000..942ec3356 --- /dev/null +++ b/platforms/esigner/src/lib/components/UserMenuDropdown.svelte @@ -0,0 +1,88 @@ + + +
+ + + {#if isOpen} + +
+ + +
+ +
+
+ {$currentUser?.name || $currentUser?.ename || 'User'} +
+ {#if $currentUser?.ename && $currentUser?.name} +
{$currentUser.ename}
+ {/if} +
+ + +
+ +
+
+ {/if} +
+ diff --git a/platforms/esigner/src/lib/stores/auth.ts b/platforms/esigner/src/lib/stores/auth.ts new file mode 100644 index 000000000..0945d8d27 --- /dev/null +++ b/platforms/esigner/src/lib/stores/auth.ts @@ -0,0 +1,77 @@ +import { writable } from 'svelte/store'; +import { apiClient, setAuthToken, removeAuthToken, removeAuthId } from '$lib/utils/axios'; + +export const isAuthenticated = writable(false); +export const currentUser = writable(null); +export const authInitialized = writable(false); + +export const initializeAuth = async () => { + authInitialized.set(false); + const token = localStorage.getItem('esigner_auth_token'); + if (token) { + // Set token in axios headers immediately + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; + // Verify token is still valid by fetching current user + try { + const response = await apiClient.get('/api/users'); + if (response.data) { + currentUser.set(response.data); + isAuthenticated.set(true); + authInitialized.set(true); + return true; + } + } catch (err) { + // Token invalid, clear it + console.error('Auth token invalid:', err); + removeAuthToken(); + removeAuthId(); + delete apiClient.defaults.headers.common['Authorization']; + } + } + isAuthenticated.set(false); + currentUser.set(null); + authInitialized.set(true); + return false; +}; + +export const login = async (token: string, user?: any) => { + // Store token in localStorage first + setAuthToken(token); + // Set token in axios headers + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; + + // Set user if provided + if (user) { + currentUser.set(user); + isAuthenticated.set(true); + } + + // Verify by fetching user to ensure token is valid + try { + const response = await apiClient.get('/api/users'); + if (response.data) { + currentUser.set(response.data); + isAuthenticated.set(true); + return true; + } + } catch (err) { + console.error('Failed to verify login:', err); + // If verification fails, clear everything + removeAuthToken(); + removeAuthId(); + delete apiClient.defaults.headers.common['Authorization']; + isAuthenticated.set(false); + currentUser.set(null); + return false; + } + return true; +}; + +export const logout = () => { + removeAuthToken(); + removeAuthId(); + delete apiClient.defaults.headers.common['Authorization']; + isAuthenticated.set(false); + currentUser.set(null); +}; + diff --git a/platforms/esigner/src/lib/stores/files.ts b/platforms/esigner/src/lib/stores/files.ts new file mode 100644 index 000000000..c594ed940 --- /dev/null +++ b/platforms/esigner/src/lib/stores/files.ts @@ -0,0 +1,111 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; +import type { Writable } from 'svelte/store'; + +export interface Signature { + id: string; + userId: string; + user?: { + id: string; + name: string; + ename: string; + avatarUrl?: string; + }; + createdAt: string; +} + +export interface Document { + id: string; + name: string; + displayName?: string; + description?: string; + mimeType: string; + size: number; + md5Hash: string; + ownerId: string; + owner?: { + id: string; + name: string; + ename: string; + }; + createdAt: string; + updatedAt: string; + status: 'draft' | 'pending' | 'partially_signed' | 'fully_signed'; + totalSignees: number; + signedCount: number; + pendingCount: number; + declinedCount: number; + signatures: Signature[]; +} + +// Keep File for backward compatibility (used in upload) +export type File = globalThis.File; + +export const documents: Writable = writable([]); +export const isLoading = writable(false); +export const error = writable(null); + +// Keep files alias for backward compatibility +export const files = documents; + +export const fetchDocuments = async () => { + try { + isLoading.set(true); + error.set(null); + const response = await apiClient.get('/api/files'); + documents.set(response.data); + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to fetch documents'); + throw err; + } finally { + isLoading.set(false); + } +}; + +// Keep fetchFiles alias for backward compatibility +export const fetchFiles = fetchDocuments; + +export const uploadFile = async (file: File, displayName?: string, description?: string) => { + try { + isLoading.set(true); + error.set(null); + const formData = new FormData(); + formData.append('file', file); + if (displayName) { + formData.append('displayName', displayName); + } + if (description) { + formData.append('description', description); + } + const response = await apiClient.post('/api/files', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + await fetchDocuments(); + return response.data; + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to upload file'); + throw err; + } finally { + isLoading.set(false); + } +}; + +export const deleteDocument = async (documentId: string) => { + try { + isLoading.set(true); + error.set(null); + await apiClient.delete(`/api/files/${documentId}`); + await fetchDocuments(); + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to delete document'); + throw err; + } finally { + isLoading.set(false); + } +}; + +// Keep deleteFile alias for backward compatibility +export const deleteFile = deleteDocument; + diff --git a/platforms/esigner/src/lib/stores/invitations.ts b/platforms/esigner/src/lib/stores/invitations.ts new file mode 100644 index 000000000..656f898c2 --- /dev/null +++ b/platforms/esigner/src/lib/stores/invitations.ts @@ -0,0 +1,77 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; +import type { Writable } from 'svelte/store'; + +export interface Invitation { + id: string; + fileId: string; + userId: string; + status: 'pending' | 'signed' | 'declined'; + invitedAt: string; + signedAt?: string; + declinedAt?: string; + file?: { + id: string; + name: string; + displayName?: string | null; + description?: string | null; + mimeType: string; + size: number; + ownerId: string; + createdAt: string; + }; + user?: { + id: string; + name: string; + ename: string; + avatarUrl?: string; + }; +} + +export const invitations: Writable = writable([]); +export const isLoading = writable(false); +export const error = writable(null); + +export const fetchInvitations = async () => { + try { + isLoading.set(true); + error.set(null); + const response = await apiClient.get('/api/invitations'); + invitations.set(response.data); + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to fetch invitations'); + throw err; + } finally { + isLoading.set(false); + } +}; + +export const inviteSignees = async (fileId: string, userIds: string[]) => { + try { + isLoading.set(true); + error.set(null); + const response = await apiClient.post(`/api/files/${fileId}/invite`, { userIds }); + return response.data; + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to invite signees'); + throw err; + } finally { + isLoading.set(false); + } +}; + +export const declineInvitation = async (invitationId: string) => { + try { + isLoading.set(true); + error.set(null); + await apiClient.post(`/api/invitations/${invitationId}/decline`); + await fetchInvitations(); + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to decline invitation'); + throw err; + } finally { + isLoading.set(false); + } +}; + + diff --git a/platforms/esigner/src/lib/stores/signatures.ts b/platforms/esigner/src/lib/stores/signatures.ts new file mode 100644 index 000000000..9458f8b0d --- /dev/null +++ b/platforms/esigner/src/lib/stores/signatures.ts @@ -0,0 +1,59 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; +import type { Writable } from 'svelte/store'; + +export interface Signature { + id: string; + userId: string; + md5Hash: string; + message: string; + signature: string; + publicKey: string; + createdAt: string; + user?: { + id: string; + name: string; + ename: string; + avatarUrl?: string; + }; +} + +export interface SigningSession { + sessionId: string; + qrData: string; + expiresAt: string; +} + +export const signatures: Writable = writable([]); +export const isLoading = writable(false); +export const error = writable(null); + +export const fetchFileSignatures = async (fileId: string) => { + try { + isLoading.set(true); + error.set(null); + const response = await apiClient.get(`/api/files/${fileId}/signatures`); + signatures.set(response.data); + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to fetch signatures'); + throw err; + } finally { + isLoading.set(false); + } +}; + +export const createSigningSession = async (fileId: string): Promise => { + try { + isLoading.set(true); + error.set(null); + const response = await apiClient.post('/api/signatures/session', { fileId }); + return response.data; + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to create signing session'); + throw err; + } finally { + isLoading.set(false); + } +}; + + diff --git a/platforms/esigner/src/lib/stores/toast.ts b/platforms/esigner/src/lib/stores/toast.ts new file mode 100644 index 000000000..34c33d965 --- /dev/null +++ b/platforms/esigner/src/lib/stores/toast.ts @@ -0,0 +1,40 @@ +import { writable } from 'svelte/store'; + +export interface Toast { + id: string; + message: string; + type: 'success' | 'error' | 'info' | 'warning'; + duration?: number; +} + +export const toasts = writable([]); + +let toastIdCounter = 0; + +export function showToast(message: string, type: Toast['type'] = 'info', duration: number = 3000) { + const id = `toast-${toastIdCounter++}`; + const toast: Toast = { id, message, type, duration }; + + toasts.update((current) => [...current, toast]); + + if (duration > 0) { + setTimeout(() => { + removeToast(id); + }, duration); + } + + return id; +} + +export function removeToast(id: string) { + toasts.update((current) => current.filter((t) => t.id !== id)); +} + +export const toast = { + success: (message: string, duration?: number) => showToast(message, 'success', duration), + error: (message: string, duration?: number) => showToast(message, 'error', duration), + info: (message: string, duration?: number) => showToast(message, 'info', duration), + warning: (message: string, duration?: number) => showToast(message, 'warning', duration), +}; + + diff --git a/platforms/esigner/src/lib/utils/axios.ts b/platforms/esigner/src/lib/utils/axios.ts new file mode 100644 index 000000000..4ee855bd3 --- /dev/null +++ b/platforms/esigner/src/lib/utils/axios.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import { PUBLIC_ESIGNER_BASE_URL } from '$env/static/public'; + +const API_BASE_URL = PUBLIC_ESIGNER_BASE_URL || 'http://localhost:3004'; + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export const setAuthToken = (token: string) => { + localStorage.setItem('esigner_auth_token', token); + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; +}; + +export const removeAuthToken = () => { + localStorage.removeItem('esigner_auth_token'); + delete apiClient.defaults.headers.common['Authorization']; +}; + +export const removeAuthId = () => { + localStorage.removeItem('esigner_auth_id'); +}; + diff --git a/platforms/esigner/src/routes/(auth)/auth/+page.svelte b/platforms/esigner/src/routes/(auth)/auth/+page.svelte new file mode 100644 index 000000000..6a9660227 --- /dev/null +++ b/platforms/esigner/src/routes/(auth)/auth/+page.svelte @@ -0,0 +1,203 @@ + + +
+
+ +
+
+ + + +
+
+

eSigner

+

Sign documents with your eID Wallet

+
+ +
+

+ {#if isMobileDevice()} + Login with your eID Wallet + {:else} + Scan the QR code using your eID App to login + {/if} +

+ {#if errorMessage} +
+

Authentication Error

+

{errorMessage}

+
+ {/if} + {#if qrData} + {#if isMobileDevice()} +
+ + Login with eID Wallet + +
+ Click the button to open your eID wallet app +
+
+ {:else} +
+ {/if} + {/if} + +

+ The {isMobileDevice() ? 'button' : 'code'} is valid for 60 seconds + Please refresh the page if it expires +

+ +

+ You are entering eSigner - a document signing platform built on the Web 3.0 Data Space + (W3DS) architecture. Sign documents securely with your eID Wallet. +

+
+
+ diff --git a/platforms/esigner/src/routes/(protected)/+layout.svelte b/platforms/esigner/src/routes/(protected)/+layout.svelte new file mode 100644 index 000000000..40ee162d6 --- /dev/null +++ b/platforms/esigner/src/routes/(protected)/+layout.svelte @@ -0,0 +1,24 @@ + + +
+
+
+
+
+
+ + + +
+

eSigner

+
+ +
+
+
+ + +
+ diff --git a/platforms/esigner/src/routes/(protected)/files/+page.svelte b/platforms/esigner/src/routes/(protected)/files/+page.svelte new file mode 100644 index 000000000..dd5f8304e --- /dev/null +++ b/platforms/esigner/src/routes/(protected)/files/+page.svelte @@ -0,0 +1,167 @@ + + +
+ +
+
+

Signature Containers

+

Manage your signature containers and signed files

+
+ + + New Signature Container + +
+ + + {#if $invitations.length > 0} +
+

Pending Signing Requests

+
+ {#each $invitations as inv} +
+
+

{inv.file?.displayName || inv.file?.name || 'Unknown Signature Container'}

+ {#if inv.file?.description} +

{inv.file.description}

+ {/if} +

Invited {new Date(inv.invitedAt).toLocaleDateString()}

+
+ + View & Sign + +
+ {/each} +
+
+ {/if} + + + +
diff --git a/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte b/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte new file mode 100644 index 000000000..71341adc3 --- /dev/null +++ b/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte @@ -0,0 +1,554 @@ + + + +
+ +
+ + + + + Back to Signature Containers + +

{file?.displayName || file?.name || 'Signature Container'}

+ {#if file?.description} +

{file.description}

+ {/if} +
+ +
+ {#if isLoading} +
+
+
+

Loading...

+
+
+ {:else if file} + +
+ {#if previewUrl} + {#if file.mimeType?.startsWith('image/')} +
+ {file.name} +
+ {:else if file.mimeType === 'application/pdf'} +
+ +
+ {:else} +
+
📄
+

Preview not available for this file type

+

{file.mimeType}

+ +
+ {/if} + {:else} +
+
📄
+

Preview not available

+ +
+ {/if} +
+ + +
+
+ +
+

Signature Container

+
+
+

Name

+

{file.displayName || file.name}

+
+ {#if file.description} +
+

Description

+

{file.description}

+
+ {/if} +
+
+ + +
+

File Information

+
+
+ File Name: + {file.name} +
+
+ Size: + {formatFileSize(file.size)} +
+
+ Type: + {file.mimeType} +
+
+ Created: + {new Date(file.createdAt).toLocaleDateString()} +
+
+
+ + +
+

Actions

+
+ + {#if !hasUserSigned} + + {:else} + + {/if} +
+
+ + +
+

+ Signees ({invitations.length}) +

+ {#if invitations.length === 0} +

No signees invited yet

+ {:else} +
+ {#each getCombinedSignees() as signee} +
+
+ + {signee.user?.name?.[0] || signee.user?.ename?.[0] || '?'} + +
+
+

+ {signee.user?.name || signee.user?.ename || 'Unknown User'} +

+

+ {#if signee.hasSigned && signee.signature} + Signed on {new Date(signee.signature.createdAt).toLocaleDateString()} + {:else} + Invited {new Date(signee.invitedAt).toLocaleDateString()} + {/if} +

+
+ {#if signee.hasSigned} + ✓ Signed + {:else if signee.status === 'declined'} + + Declined + + {:else} + + Pending + + {/if} +
+ {/each} +
+ {/if} +
+
+
+ {/if} +
+
+ + + {#if showDownloadModal} +
showDownloadModal = false} + > +
e.stopPropagation()} + > +

Download Options

+

Choose what you would like to download:

+ +
+ + + +
+ + +
+
+ {/if} + + + {#if showSignModal && signingSession} +
{ + showSignModal = false; + sseConnection?.close(); + }} + > +
e.stopPropagation()} + > +

Sign Signature Container

+

Scan this QR code with your eID Wallet to sign the signature container

+ +
+
+
+ +

+ Waiting for signature... +

+ + +
+
+ {/if} + diff --git a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte new file mode 100644 index 000000000..a62f4a119 --- /dev/null +++ b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte @@ -0,0 +1,568 @@ + + +
+ +
+ + + + + Back to Signature Containers + +

New Signature Container

+
+ +
+
+
+
= 1 ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600' + }`}> + 1 +
+ = 1 ? 'text-blue-600 font-medium' : 'text-gray-600'}>Upload File +
+
+
= 2 ? 'bg-blue-600' : ''}`} style="width: {currentStep >= 2 ? '100%' : '0%'}">
+
+
+
= 2 ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600' + }`}> + 2 +
+ = 2 ? 'text-blue-600 font-medium' : 'text-gray-600'}>Invite Signees +
+
+
= 3 ? 'bg-blue-600' : ''}`} style="width: {currentStep >= 3 ? '100%' : '0%'}">
+
+
+
= 3 ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600' + }`}> + 3 +
+ = 3 ? 'text-blue-600 font-medium' : 'text-gray-600'}>Review +
+
+
+ + + {#if currentStep === 1} +
+

Upload File

+ + +
+

Upload New File

+
+ {#if uploadedFile} +
+
{getFileIcon(uploadedFile.type)}
+

{uploadedFile.name}

+

{formatFileSize(uploadedFile.size)}

+
+ + {:else} +
📤
+

Drag and drop a file here

+

or

+ + + {#if isLoading} +

Uploading...

+ {/if} + {/if} +
+
+ + +
+

Or Select Existing File

+ {#if $files.length === 0} +

No files available

+ {:else} +
+ {#each $files as file} + + {/each} +
+ {/if} +
+ + + {#if selectedFile || uploadedFile} +
+
+ + +

Give this signature container a descriptive name

+
+
+ + +
+
+ {/if} + +
+ +
+
+ {/if} + + + {#if currentStep === 2} +
+

Invite Signees

+ + +
+
+ {getFileIcon(selectedFile?.mimeType || '')} +
+

{selectedFile?.name}

+

{formatFileSize(selectedFile?.size || 0)}

+
+
+
+ + +
+ + + {#if searchResults.length > 0} +
+ {#each searchResults as user} + + {/each} +
+ {/if} +
+ + +
+

+ Selected Signees ({selectedUsers.length}) +

+ {#if selectedUsers.length === 0} +

+ No additional signees. You can skip this step to create a self-signed document. +

+ {:else} +
+ {#each selectedUsers as user} +
+
+
+ + {user.name?.[0] || user.ename?.[0] || '?'} + +
+
+

{user.name || 'No name'}

+

{user.ename}

+
+
+ +
+ {/each} +
+ {/if} +
+ +
+ +
+ + +
+
+
+ {/if} + + + {#if currentStep === 3} +
+

Review & Confirm

+ + +
+

File

+
+ {getFileIcon(selectedFile?.mimeType || '')} +
+

{selectedFile?.name}

+

{formatFileSize(selectedFile?.size || 0)}

+
+
+
+ + +
+

Signees ({selectedUsers.length + 1})

+
+ +
+
+ You +
+
+

You (Owner)

+

Automatically added as signee

+
+
+ + {#each selectedUsers as user} +
+
+ + {user.name?.[0] || user.ename?.[0] || '?'} + +
+
+

{user.name || 'No name'}

+

{user.ename}

+
+
+ {/each} +
+
+ + +
+

+ Note: All signees will be notified automatically once the signing container is created. +

+
+ +
+ + +
+
+ {/if} +
+ diff --git a/platforms/esigner/src/routes/+layout.svelte b/platforms/esigner/src/routes/+layout.svelte new file mode 100644 index 000000000..e7d534cf1 --- /dev/null +++ b/platforms/esigner/src/routes/+layout.svelte @@ -0,0 +1,33 @@ + + +{#if authInitComplete} + + +{:else} +
+
+
+

Loading...

+
+
+{/if} + diff --git a/platforms/esigner/src/routes/+page.svelte b/platforms/esigner/src/routes/+page.svelte new file mode 100644 index 000000000..cc1e18550 --- /dev/null +++ b/platforms/esigner/src/routes/+page.svelte @@ -0,0 +1,26 @@ + + +
+

Loading...

+
+ + diff --git a/platforms/esigner/svelte.config.js b/platforms/esigner/svelte.config.js new file mode 100644 index 000000000..8c3b30445 --- /dev/null +++ b/platforms/esigner/svelte.config.js @@ -0,0 +1,16 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + env: { + dir: '../../' + } + } +}; + +export default config; + + diff --git a/platforms/esigner/tsconfig.json b/platforms/esigner/tsconfig.json new file mode 100644 index 000000000..2525103ca --- /dev/null +++ b/platforms/esigner/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} + + diff --git a/platforms/esigner/vite.config.ts b/platforms/esigner/vite.config.ts new file mode 100644 index 000000000..41536d4d5 --- /dev/null +++ b/platforms/esigner/vite.config.ts @@ -0,0 +1,16 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + allowedHosts: [ + 'esigner.w3ds-prototype.merul.org', + 'esigner.staging.metastate.foundation', + 'esigner.w3ds.metastate.foundation' + ] + } +}); + + diff --git a/platforms/pictique/src/routes/(protected)/messages/+page.svelte b/platforms/pictique/src/routes/(protected)/messages/+page.svelte index 22cd2883b..774a88fa6 100644 --- a/platforms/pictique/src/routes/(protected)/messages/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/messages/+page.svelte @@ -62,18 +62,24 @@ : members[0]?.avatarUrl || 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/people-fill.svg'; - // For groups, prioritize the group name, fallback to member names - // For direct messages, use the other person's name + // For groups (3+ people), prioritize the group name, fallback to member names + // For direct messages (2 people), always use the other person's name, never the group name const displayName = isGroup ? c.name || memberNames.join(', ') // Group name first, then member names - : c.name || members[0]?.name || members[0]?.handle || 'Unknown User'; + : members[0]?.name || members[0]?.handle || members[0]?.ename || 'Unknown User'; + + // Trim system message prefix from preview text + let previewText = c.latestMessage?.text ?? 'No message yet'; + if (typeof previewText === 'string' && previewText.startsWith('$$system-message$$')) { + previewText = previewText.replace('$$system-message$$', '').trim(); + } return { id: c.id, avatar, username: displayName, unread: c.latestMessage ? !c.latestMessage.isRead : false, - text: c.latestMessage?.text ?? 'No message yet', + text: previewText, handle: displayName, name: displayName }; diff --git a/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte index 3f5ab9755..0dd46be13 100644 --- a/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte @@ -5,6 +5,8 @@ import { apiClient, getAuthToken } from '$lib/utils/axios'; import moment from 'moment'; import { onMount } from 'svelte'; + import { heading } from '../../../store'; + import type { Chat } from '$lib/types'; const id = page.params.id; let userId = $state(); @@ -156,9 +158,34 @@ } } + async function loadChatInfo() { + try { + // Load chat info to set the header correctly + const { data: chatsData } = await apiClient.get<{ + chats: Chat[]; + }>(`/api/chats?page=1&limit=100`); + + const chat = chatsData.chats.find((c) => c.id === id); + if (chat && userId) { + const members = chat.participants.filter((u) => u.id !== userId); + const isGroup = members.length > 1; + + // For 2-person chats, show the other person's name, not the group name + const displayName = isGroup + ? chat.name || members.map((m) => m.name ?? m.handle ?? m.ename).join(', ') + : members[0]?.name || members[0]?.handle || members[0]?.ename || 'Unknown User'; + + heading.set(displayName); + } + } catch (error) { + console.error('Failed to load chat info:', error); + } + } + onMount(async () => { const { data: userData } = await apiClient.get('/api/users'); userId = userData.id; + await loadChatInfo(); watchEventStream(); }); diff --git a/platforms/registry/src/index.ts b/platforms/registry/src/index.ts index 7290191a3..ed9b36191 100644 --- a/platforms/registry/src/index.ts +++ b/platforms/registry/src/index.ts @@ -169,7 +169,8 @@ server.get("/platforms", async (request, reply) => { process.env.VITE_DREAMSYNC_BASE_URL, process.env.VITE_EREPUTATION_BASE_URL, process.env.VITE_ECURRENCY_BASE_URL, - process.env.PUBLIC_EMOVER_BASE_URL + process.env.PUBLIC_EMOVER_BASE_URL, + process.env.PUBLIC_ESIGNER_BASE_URL ]; return platforms; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a815082e..9b03d1e85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2074,6 +2074,170 @@ importers: specifier: ^5.3.3 version: 5.8.2 + platforms/esigner: + dependencies: + '@sveltejs/adapter-node': + specifier: ^5.2.12 + version: 5.4.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1))) + axios: + specifier: ^1.6.7 + version: 1.13.2 + svelte-qrcode: + specifier: ^1.0.1 + version: 1.0.1 + svelte-qrcode-action: + specifier: ^1.0.2 + version: 1.0.2(svelte@5.45.10) + tailwind-merge: + specifier: ^3.0.2 + version: 3.4.0 + devDependencies: + '@eslint/compat': + specifier: ^1.2.5 + version: 1.4.1(eslint@9.39.1(jiti@2.6.1)) + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.1 + '@sveltejs/adapter-static': + specifier: ^3.0.8 + version: 3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1))) + '@sveltejs/kit': + specifier: ^2.16.0 + version: 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)) + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.1.17(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + eslint: + specifier: ^9.18.0 + version: 9.39.1(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.0.1 + version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-svelte: + specifier: ^3.0.0 + version: 3.13.1(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.10)(ts-node@10.9.2(@types/node@24.10.3)(typescript@5.8.2)) + globals: + specifier: ^16.0.0 + version: 16.5.0 + prettier: + specifier: ^3.4.2 + version: 3.7.4 + prettier-plugin-svelte: + specifier: ^3.3.3 + version: 3.4.0(prettier@3.7.4)(svelte@5.45.10) + prettier-plugin-tailwindcss: + specifier: ^0.7.0 + version: 0.7.2(prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.10))(prettier@3.7.4) + svelte: + specifier: ^5.0.0 + version: 5.45.10 + svelte-check: + specifier: ^4.0.0 + version: 4.3.4(picomatch@4.0.3)(svelte@5.45.10)(typescript@5.8.2) + tailwindcss: + specifier: ^4.0.0 + version: 4.1.17 + typescript: + specifier: ^5.0.0 + version: 5.8.2 + typescript-eslint: + specifier: ^8.20.0 + version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.2) + vite: + specifier: ^6.2.6 + version: 6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1) + + platforms/esigner-api: + dependencies: + axios: + specifier: ^1.6.7 + version: 1.13.2 + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + eventsource-polyfill: + specifier: ^0.9.6 + version: 0.9.6 + express: + specifier: ^4.18.2 + version: 4.22.1 + graphql-request: + specifier: ^6.1.0 + version: 6.1.0(encoding@0.1.13)(graphql@16.12.0) + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + multer: + specifier: ^1.4.5-lts.1 + version: 1.4.5-lts.2 + pg: + specifier: ^8.11.3 + version: 8.16.3 + reflect-metadata: + specifier: ^0.2.1 + version: 0.2.2 + signature-validator: + specifier: workspace:* + version: link:../../infrastructure/signature-validator + typeorm: + specifier: ^0.3.24 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.8.2)(pg@8.16.3)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + uuid: + specifier: ^9.0.1 + version: 9.0.1 + web3-adapter: + specifier: workspace:* + version: link:../../infrastructure/web3-adapter + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/jsonwebtoken': + specifier: ^9.0.5 + version: 9.0.10 + '@types/multer': + specifier: ^1.4.11 + version: 1.4.13 + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + '@types/pg': + specifier: ^8.11.2 + version: 8.16.0 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + '@typescript-eslint/eslint-plugin': + specifier: ^7.0.1 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2) + '@typescript-eslint/parser': + specifier: ^7.0.1 + version: 7.18.0(eslint@8.57.1)(typescript@5.8.2) + eslint: + specifier: ^8.56.0 + version: 8.57.1 + nodemon: + specifier: ^3.0.3 + version: 3.1.11 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + typescript: + specifier: ^5.3.3 + version: 5.8.2 + platforms/evoting-api: dependencies: axios: @@ -4017,10 +4181,6 @@ packages: resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7143,6 +7303,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@1.4.13': + resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} @@ -7905,6 +8068,9 @@ packages: resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} engines: {node: '>= 6.0.0'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + aproba@2.1.0: resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} @@ -8298,6 +8464,10 @@ packages: builtin-status-codes@3.0.0: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + byline@5.0.0: resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} engines: {node: '>=0.10.0'} @@ -8562,6 +8732,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + concurrently@8.2.2: resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} engines: {node: ^14.13.0 || >=16.0.0} @@ -11719,6 +11893,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@1.4.5-lts.2: + resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==} + engines: {node: '>= 6.0.0'} + deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. + multiformats@13.3.2: resolution: {integrity: sha512-qbB0CQDt3QKfiAzZ5ZYjLFOs+zW43vA4uyM8g27PeEuXZybUOFyjrVdP93HPBHMoglibwfkdVwbzfUq8qGcH6g==} @@ -13372,6 +13551,10 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -14038,6 +14221,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typeorm-ts-node-commonjs@0.3.20: resolution: {integrity: sha512-lXjve7w7OcF3s5+dHnCsrBjUTukpVeiS0bDe5KDXWcDx8TyRW0GTTg9kjWgHzFgHgBIBBu4WGXM0iuGpEgaV9g==} hasBin: true @@ -15849,20 +16035,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/eslintrc@3.3.1': - dependencies: - ajv: 6.12.6 - debug: 4.4.3(supports-color@5.5.0) - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 @@ -16487,7 +16659,7 @@ snapshots: '@grpc/grpc-js@1.7.3': dependencies: '@grpc/proto-loader': 0.7.15 - '@types/node': 18.19.130 + '@types/node': 20.19.26 '@grpc/proto-loader@0.6.13': dependencies: @@ -16722,7 +16894,7 @@ snapshots: '@jest/console@28.1.3': dependencies: '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 chalk: 4.1.2 jest-message-util: 28.1.3 jest-util: 28.1.3 @@ -16744,14 +16916,14 @@ snapshots: '@jest/test-result': 28.1.3 '@jest/transform': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 28.1.3 - jest-config: 28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) + jest-config: 28.1.3(@types/node@20.19.26)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) jest-haste-map: 28.1.3 jest-message-util: 28.1.3 jest-regex-util: 28.0.2 @@ -16811,7 +16983,7 @@ snapshots: dependencies: '@jest/fake-timers': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 jest-mock: 28.1.3 '@jest/environment@29.7.0': @@ -16847,7 +17019,7 @@ snapshots: dependencies: '@jest/types': 28.1.3 '@sinonjs/fake-timers': 9.1.2 - '@types/node': 18.19.130 + '@types/node': 20.19.26 jest-message-util: 28.1.3 jest-mock: 28.1.3 jest-util: 28.1.3 @@ -16886,7 +17058,7 @@ snapshots: '@jest/transform': 28.1.3 '@jest/types': 28.1.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 18.19.130 + '@types/node': 20.19.26 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -17030,7 +17202,7 @@ snapshots: '@jest/schemas': 28.1.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 18.19.130 + '@types/node': 20.19.26 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -19929,7 +20101,7 @@ snapshots: '@types/jsdom@16.2.15': dependencies: - '@types/node': 18.19.130 + '@types/node': 20.19.26 '@types/parse5': 6.0.3 '@types/tough-cookie': 4.0.5 @@ -19973,6 +20145,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@1.4.13': + dependencies: + '@types/express': 4.17.25 + '@types/node-cron@3.0.11': {} '@types/node-fetch@2.6.13': @@ -21076,6 +21252,8 @@ snapshots: app-root-path@3.1.0: {} + append-field@1.0.0: {} + aproba@2.1.0: optional: true @@ -21591,6 +21769,10 @@ snapshots: builtin-status-codes@3.0.0: {} + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + byline@5.0.0: {} bytes@3.1.2: {} @@ -21863,6 +22045,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + concurrently@8.2.2: dependencies: chalk: 4.1.2 @@ -23236,7 +23425,7 @@ snapshots: '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 + '@eslint/eslintrc': 3.3.3 '@eslint/js': 9.39.1 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -24663,7 +24852,7 @@ snapshots: '@jest/expect': 28.1.3 '@jest/test-result': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 chalk: 4.1.2 co: 4.6.0 dedent: 0.7.0 @@ -24794,6 +24983,36 @@ snapshots: transitivePeerDependencies: - supports-color + jest-config@28.1.3(@types/node@20.19.26)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)): + dependencies: + '@babel/core': 7.28.5 + '@jest/test-sequencer': 28.1.3 + '@jest/types': 28.1.3 + babel-jest: 28.1.3(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 28.1.3 + jest-environment-node: 28.1.3 + jest-get-type: 28.0.2 + jest-regex-util: 28.0.2 + jest-resolve: 28.1.3 + jest-runner: 28.1.3 + jest-util: 28.1.3 + jest-validate: 28.1.3 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 28.1.3 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.26 + ts-node: 10.9.2(@types/node@18.19.130)(typescript@5.0.4) + transitivePeerDependencies: + - supports-color + jest-config@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)): dependencies: '@babel/core': 7.28.5 @@ -24914,7 +25133,7 @@ snapshots: '@jest/environment': 28.1.3 '@jest/fake-timers': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 jest-mock: 28.1.3 jest-util: 28.1.3 @@ -24935,7 +25154,7 @@ snapshots: dependencies: '@jest/types': 28.1.3 '@types/graceful-fs': 4.1.9 - '@types/node': 18.19.130 + '@types/node': 20.19.26 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -25014,7 +25233,7 @@ snapshots: jest-mock@28.1.3: dependencies: '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 jest-mock@29.7.0: dependencies: @@ -25079,7 +25298,7 @@ snapshots: '@jest/test-result': 28.1.3 '@jest/transform': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 chalk: 4.1.2 emittery: 0.10.2 graceful-fs: 4.2.11 @@ -25234,7 +25453,7 @@ snapshots: jest-util@28.1.3: dependencies: '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -25271,7 +25490,7 @@ snapshots: dependencies: '@jest/test-result': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 18.19.130 + '@types/node': 20.19.26 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.10.2 @@ -25291,7 +25510,7 @@ snapshots: jest-worker@28.1.3: dependencies: - '@types/node': 18.19.130 + '@types/node': 20.19.26 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -26404,6 +26623,16 @@ snapshots: ms@2.1.3: {} + multer@1.4.5-lts.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + multiformats@13.3.2: {} mz@2.7.0: @@ -27352,7 +27581,7 @@ snapshots: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 18.19.130 + '@types/node': 20.19.26 long: 4.0.0 protobufjs@7.5.4: @@ -27367,7 +27596,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.19.2 + '@types/node': 20.19.26 long: 5.3.2 proxy-addr@2.0.7: @@ -28397,6 +28626,8 @@ snapshots: stream-shift@1.0.3: optional: true + streamsearch@1.1.0: {} + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -29292,6 +29523,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typeorm-ts-node-commonjs@0.3.20: {} typeorm@0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.8.2)(pg@8.16.3)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)):