diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60c55e9..2e0d798 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ model User { name String? role String @default("USER") googleId String? @unique + isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt wallets Wallet[] diff --git a/src/app.js b/src/app.js index 82b1ed1..df668f0 100644 --- a/src/app.js +++ b/src/app.js @@ -10,6 +10,7 @@ const { createTransactionBuilder } = require('./builders/transaction.builder'); const { createEscrowService } = require('./services/escrow.service'); const { createHorizonService } = require('./services/horizon.service'); const { createStellarService } = require('./services/stellar.service'); +const { createEmbeddedWalletProvider } = require('./providers/embedded-wallet.provider'); let config; try { @@ -43,6 +44,8 @@ const transactionBuilder = createTransactionBuilder({ server, contract, config } const escrowService = createEscrowService({ transactionBuilder, config, escrowRepository, transactionRepository }); const horizonService = createHorizonService({ server }); const stellarService = createStellarService({ config, server }); +// eslint-disable-next-line no-unused-vars +const embeddedWalletProvider = createEmbeddedWalletProvider({ config }); const app = express(); const PORT = config.PORT; @@ -52,12 +55,14 @@ app.use(express.json()); const authRoutes = require('./routes/auth.routes'); const usersRoutes = require('./routes/users.routes'); +const accountsRoutes = require('./routes/accounts.routes'); // API Routes const relayerRoutes = createRelayerRoutes({ escrowService, horizonService, stellarService }); app.use('/health', healthRoutes); app.use('/api/auth', authRoutes); app.use('/api/users/me', authenticate, usersRoutes); +app.use('/api/accounts/me', authenticate, accountsRoutes); app.use('/api/relayer', relayerRoutes); // Error Handling Middleware diff --git a/src/providers/embedded-wallet.provider.js b/src/providers/embedded-wallet.provider.js new file mode 100644 index 0000000..c273e6d --- /dev/null +++ b/src/providers/embedded-wallet.provider.js @@ -0,0 +1,62 @@ +const crypto = require('crypto'); + +/** + * @typedef {Object} IWalletProvider + * @property {function(string): Promise<{ address: string }>} createWallet - Creates a new embedded wallet for a user. + * @property {function(string): Promise<{ address: string } | null>} getWallet - Retrieves the embedded wallet address for a user. + */ + +/** + * Factory function for the generic Embedded Wallet Provider abstraction. + * + * This provider satisfies the IWalletProvider interface. It acts as an adapter + * to an underlying embedded wallet infrastructure (e.g., Privy, Turnkey). + * By strictly returning only public addresses (and never private keys), it ensures + * the backend remains completely non-custodial and safely agnostic to the actual provider. + * + * @param {Object} [deps] - Dependencies + * @param {Object} [deps.config] - Application configuration. + * @returns {IWalletProvider} + */ +const createEmbeddedWalletProvider = ({ _config } = {}) => { + // In-memory store for mocked wallet addresses. + // In a real integration, this would communicate with the provider's API. + const mockWallets = new Map(); + + /** + * Creates a new embedded wallet for a user. + * + * @param {string} userId - The unique identifier of the user. + * @returns {Promise<{ address: string }>} The public address of the generated wallet. + */ + const createWallet = async (userId) => { + if (!userId) { + throw new Error('userId is required to create a wallet'); + } + + // Stub: Generate a deterministic mock address based on userId + const address = `G_MOCK_${crypto.createHash('sha256').update(userId).digest('hex').substring(0, 40).toUpperCase()}`; + mockWallets.set(userId, address); + + return { address }; + }; + + /** + * Retrieves the embedded wallet address for a user. + * + * @param {string} userId - The unique identifier of the user. + * @returns {Promise<{ address: string } | null>} The public address, or null if not found. + */ + const getWallet = async (userId) => { + if (!userId) { + throw new Error('userId is required to get a wallet'); + } + + const address = mockWallets.get(userId) || null; + return address ? { address } : null; + }; + + return { createWallet, getWallet }; +}; + +module.exports = { createEmbeddedWalletProvider }; diff --git a/src/routes/accounts.routes.js b/src/routes/accounts.routes.js new file mode 100644 index 0000000..2fcbee1 --- /dev/null +++ b/src/routes/accounts.routes.js @@ -0,0 +1,30 @@ +const express = require('express'); +const router = express.Router(); +const { UserRepository } = require('../repositories/user.repository'); +const prisma = require('../clients/prisma.client'); +const AppError = require('../errors/AppError'); + +const userRepository = new UserRepository(prisma); + +router.get('/', async (req, res, next) => { + try { + const user = await userRepository.findById(req.user.id); + if (!user) { + throw new AppError('User not found', 404); + } + + // Return only logical account state + res.status(200).json({ + success: true, + message: 'Account status retrieved successfully', + data: { + isActive: user.isActive, + createdAt: user.createdAt, + }, + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/tests/providers/embedded-wallet.provider.test.js b/tests/providers/embedded-wallet.provider.test.js new file mode 100644 index 0000000..7477c96 --- /dev/null +++ b/tests/providers/embedded-wallet.provider.test.js @@ -0,0 +1,52 @@ +const { createEmbeddedWalletProvider } = require('../../src/providers/embedded-wallet.provider'); + +describe('EmbeddedWalletProvider', () => { + let provider; + + beforeEach(() => { + provider = createEmbeddedWalletProvider({ config: { NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015' } }); + }); + + describe('createWallet', () => { + it('creates a deterministic mock wallet address for a user ID', async () => { + const userId = 'user-123'; + const result = await provider.createWallet(userId); + + expect(result).toHaveProperty('address'); + expect(result.address.startsWith('G_MOCK_')).toBe(true); + expect(result.address.length).toBeGreaterThan(10); + }); + + it('returns the same address for the same user ID', async () => { + const userId = 'user-456'; + const result1 = await provider.createWallet(userId); + const result2 = await provider.createWallet(userId); + + expect(result1.address).toBe(result2.address); + }); + + it('throws an error if userId is missing', async () => { + await expect(provider.createWallet()).rejects.toThrow('userId is required'); + }); + }); + + describe('getWallet', () => { + it('returns null for a user without a wallet', async () => { + const result = await provider.getWallet('unknown-user'); + expect(result).toBeNull(); + }); + + it('returns the generated wallet for a user', async () => { + const userId = 'user-789'; + const created = await provider.createWallet(userId); + + const retrieved = await provider.getWallet(userId); + expect(retrieved).not.toBeNull(); + expect(retrieved.address).toBe(created.address); + }); + + it('throws an error if userId is missing', async () => { + await expect(provider.getWallet()).rejects.toThrow('userId is required'); + }); + }); +}); diff --git a/tests/routes/accounts.routes.test.js b/tests/routes/accounts.routes.test.js new file mode 100644 index 0000000..b28fba9 --- /dev/null +++ b/tests/routes/accounts.routes.test.js @@ -0,0 +1,84 @@ +const request = require('supertest'); +const express = require('express'); +const errorHandler = require('../../src/middleware/error.middleware'); +const AppError = require('../../src/errors/AppError'); + +// Mock dependencies +jest.mock('../../src/clients/prisma.client', () => ({})); + +const mockFindById = jest.fn(); + +jest.mock('../../src/repositories/user.repository', () => { + return { + UserRepository: jest.fn().mockImplementation(() => ({ + findById: mockFindById, + })), + }; +}); + +const accountsRoutes = require('../../src/routes/accounts.routes'); + +const app = express(); +app.use(express.json()); +app.use((req, res, next) => { + // Mock authenticate middleware behavior + if (req.headers.authorization === 'Bearer valid-token') { + req.user = { id: 'user-123' }; + next(); + } else if (req.headers.authorization === 'Bearer valid-token-not-found') { + req.user = { id: 'not-found' }; + next(); + } else { + next(new AppError('Unauthorized', 401)); + } +}); +app.use('/api/accounts/me', accountsRoutes); +app.use(errorHandler); + +describe('Accounts Routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/accounts/me', () => { + it('returns 200 and logical account status for authenticated user', async () => { + mockFindById.mockResolvedValue({ + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + role: 'USER', + passwordHash: 'secret-hash', + isActive: true, + createdAt: '2023-01-01T00:00:00.000Z', + }); + + const res = await request(app) + .get('/api/accounts/me') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.isActive).toBe(true); + expect(res.body.data.createdAt).toBe('2023-01-01T00:00:00.000Z'); + expect(res.body.data).not.toHaveProperty('email'); + expect(res.body.data).not.toHaveProperty('passwordHash'); + }); + + it('returns 404 if authenticated user not found in DB', async () => { + mockFindById.mockResolvedValue(null); + + const res = await request(app) + .get('/api/accounts/me') + .set('Authorization', 'Bearer valid-token-not-found'); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('User not found'); + }); + + it('returns 401 if unauthenticated', async () => { + const res = await request(app).get('/api/accounts/me'); + expect(res.status).toBe(401); + }); + }); +});