From b4b876b7aafae6043bcb3c6a90d1e171333d6acd Mon Sep 17 00:00:00 2001 From: Strategy Dan Date: Wed, 1 Jul 2026 09:06:11 +0100 Subject: [PATCH 1/2] refactor: standardize API responses across all controllers using JSend spec --- .../register-multisigner.test.js | 31 ++++++---- stellar-payment-platform/server.js | 40 +++---------- stellar-payment-platform/server.test.js | 51 +++++++---------- .../src/routes/v1/federationRoutes.js | 28 ++++------ .../src/routes/v1/receiptRoutes.js | 7 ++- .../src/routes/v1/userRoutes.js | 56 +++++++------------ stellar-payment-platform/src/utils/jsend.js | 5 ++ stellar-payment-platform/tests/api.test.js | 8 ++- 8 files changed, 91 insertions(+), 135 deletions(-) create mode 100644 stellar-payment-platform/src/utils/jsend.js diff --git a/stellar-payment-platform/register-multisigner.test.js b/stellar-payment-platform/register-multisigner.test.js index f18c134..866721f 100644 --- a/stellar-payment-platform/register-multisigner.test.js +++ b/stellar-payment-platform/register-multisigner.test.js @@ -200,10 +200,10 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(201); - expect(response.body.ok).toBe(true); - expect(response.body.username).toBe('alice*localhost'); - expect(response.body.address).toBe(accountId); - expect(response.body.verification.thresholdMet).toBe(true); + expect(response.body.status).toBe('success'); + expect(response.body.data.username).toBe('alice*localhost'); + expect(response.body.data.address).toBe(accountId); + expect(response.body.data.verification.thresholdMet).toBe(true); expect(verifyMultiSignerThreshold).toHaveBeenCalledWith( accountId, [accountId], @@ -238,8 +238,9 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(401); - expect(response.body.error).toBeDefined(); - expect(response.body.error).toMatch(/Signature verification failed|Insufficient signing weight/); + expect(response.body.status).toBe('fail'); + expect(response.body.data.signature).toBeDefined(); + expect(response.body.data.signature).toMatch(/Signature verification failed|Insufficient signing weight/); }); }); @@ -307,7 +308,8 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(401); - expect(response.body.error).toContain('Insufficient signing weight'); + expect(response.body.status).toBe('fail'); + expect(response.body.data.signature).toContain('Insufficient signing weight'); }); }); @@ -326,7 +328,8 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(409); - expect(response.body.error).toContain('Address already registered'); + expect(response.body.status).toBe('fail'); + expect(response.body.data.address).toContain('Address already registered'); }); it('should handle account not found error', async () => { @@ -345,7 +348,8 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(404); - expect(response.body.error).toContain('Account not found on Horizon'); + expect(response.body.status).toBe('fail'); + expect(response.body.data.address).toContain('Account not found on Horizon'); }); }); @@ -376,7 +380,8 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(201); - expect(response.body.verification).toEqual({ + expect(response.body.status).toBe('success'); + expect(response.body.data.verification).toEqual({ accountId, signerCount: 1, thresholdMet: true, @@ -411,7 +416,8 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(201); - expect(response.body.federation_address).toMatch(/^alice\*/); + expect(response.body.status).toBe('success'); + expect(response.body.data.federation_address).toMatch(/^alice\*/); }); }); @@ -442,7 +448,8 @@ describe('POST /register - Multi-Signer Threshold Verification', () => { }); expect(response.status).toBe(201); - expect(response.body.username).toBe('alice*localhost'); + expect(response.body.status).toBe('success'); + expect(response.body.data.username).toBe('alice*localhost'); }); }); }); diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index f7ffcdb..aba11bb 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -7,6 +7,7 @@ const { createClient } = require('redis'); const { prisma } = require('./prismaClient'); const { scheduleCleanupJob } = require('./src/cleanup-cron'); const Filter = require('bad-words'); +const { success, fail, error: jsendError } = require('./src/utils/jsend'); const dotenv = require('dotenv'); const timeout = require('connect-timeout'); const compression = require('compression'); @@ -518,28 +519,6 @@ app.use((err, _req, _res, next) => { next(err); }); -// Global error handling middleware -// eslint-disable-next-line no-unused-vars -app.use((err, _req, res, _next) => { - const statusCode = err.statusCode || 500; - const errorMessage = err.message || 'Internal server error'; - - if (statusCode === 500) { - const errorId = crypto.randomUUID(); - console.error(`[Error ID: ${errorId}]`, err); - return res.status(500).json({ - success: false, - error: 'Internal Server Error', - reference_id: errorId, - }); - } - - return res.status(statusCode).json({ - success: false, - error: errorMessage, - statusCode: statusCode, - }); -}); const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS, 10) || 10_000; let isShuttingDown = false; @@ -567,17 +546,16 @@ const gracefulShutdown = (server, pool, signal) => { }); }; - - -app.use((err, req, res) => { - console.error(err.stack); +// Global JSend error handling middleware +app.use((err, req, res, next) => { const statusCode = err.statusCode || 500; - res.status(statusCode).json({ - success: false, - message: err.message || 'Internal Server Error', - detail: process.env.NODE_ENV === 'development' ? err.stack : 'Check server logs for details' - }); + if (statusCode === 500) { + console.error(err); + return res.status(500).json(jsendError('Internal Server Error')); + } + + return res.status(statusCode).json(fail({ message: err.message })); }); if (require.main === module) { diff --git a/stellar-payment-platform/server.test.js b/stellar-payment-platform/server.test.js index db57a0f..eedb335 100644 --- a/stellar-payment-platform/server.test.js +++ b/stellar-payment-platform/server.test.js @@ -497,9 +497,8 @@ describe('POST /register — block secret keys', () => { .send({ username: 'alice', address: 'SBCDEFGHIJKLMNOPQRSTUVWXYZ' }); expect(res.status).toBe(400); - expect(res.body).toEqual({ - error: "Never share your Secret Key. Please register using your Public Key (starts with G)." - }); + expect(res.body.status).toBe('fail'); + expect(res.body.data.address).toContain("Never share your Secret Key"); }); test('blocks registration if address starts with s (lowercase)', async () => { @@ -508,9 +507,8 @@ describe('POST /register — block secret keys', () => { .send({ username: 'alice', address: 'sBCDEFGHIJKLMNOPQRSTUVWXYZ' }); expect(res.status).toBe(400); - expect(res.body).toEqual({ - error: "Never share your Secret Key. Please register using your Public Key (starts with G)." - }); + expect(res.body.status).toBe('fail'); + expect(res.body.data.address).toContain("Never share your Secret Key"); }); test('allows registration and continues flow if address starts with G', async () => { @@ -519,11 +517,9 @@ describe('POST /register — block secret keys', () => { .send({ username: 'alice', address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ' }); expect(res.status).toBe(201); - expect(res.body).toMatchObject({ - ok: true, - username: 'alice*localhost', - address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ' - }); + expect(res.body.status).toBe('success'); + expect(res.body.data.username).toBe('alice*localhost'); + expect(res.body.data.address).toBe('GBCDEFGHIJKLMNOPQRSTUVWXYZ'); }); test('rejects registration if Content-Type header is not application/json', async () => { @@ -533,9 +529,8 @@ describe('POST /register — block secret keys', () => { .send('username=alice&address=GBCDEFGHIJKLMNOPQRSTUVWXYZ'); expect(res.status).toBe(415); - expect(res.body).toEqual({ - error: "Unsupported Media Type. Please send application/json" - }); + expect(res.body.status).toBe('fail'); + expect(res.body.data.contentType).toContain("Unsupported Media Type"); }); test('rejects registration if Content-Type header is missing', async () => { @@ -545,9 +540,8 @@ describe('POST /register — block secret keys', () => { .send('some-raw-payload'); expect(res.status).toBe(415); - expect(res.body).toEqual({ - error: "Unsupported Media Type. Please send application/json" - }); + expect(res.body.status).toBe('fail'); + expect(res.body.data.contentType).toContain("Unsupported Media Type"); }); test('rejects 1-character local username payload', async () => { @@ -556,9 +550,8 @@ describe('POST /register — block secret keys', () => { .send({ username: 'a', address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ' }); expect(res.status).toBe(400); - expect(res.body).toEqual({ - error: "Username must be at least 3 characters long." - }); + expect(res.body.status).toBe('fail'); + expect(res.body.data.username).toContain("Username must be at least 3 characters long."); }); test('rejects 2-character local username payload', async () => { @@ -567,9 +560,8 @@ describe('POST /register — block secret keys', () => { .send({ username: 'ab', address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ' }); expect(res.status).toBe(400); - expect(res.body).toEqual({ - error: "Username must be at least 3 characters long." - }); + expect(res.body.status).toBe('fail'); + expect(res.body.data.username).toContain("Username must be at least 3 characters long."); }); test('rejects 2-character local username payload with domain suffix', async () => { @@ -578,9 +570,8 @@ describe('POST /register — block secret keys', () => { .send({ username: 'ab*domain.com', address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ' }); expect(res.status).toBe(400); - expect(res.body).toEqual({ - error: "Username must be at least 3 characters long." - }); + expect(res.body.status).toBe('fail'); + expect(res.body.data.username).toContain("Username must be at least 3 characters long."); }); test('allows 3-character username payload', async () => { @@ -589,11 +580,9 @@ describe('POST /register — block secret keys', () => { .send({ username: 'abc', address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ' }); expect(res.status).toBe(201); - expect(res.body).toMatchObject({ - ok: true, - username: 'abc*localhost', - address: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ' - }); + expect(res.body.status).toBe('success'); + expect(res.body.data.username).toBe('abc*localhost'); + expect(res.body.data.address).toBe('GBCDEFGHIJKLMNOPQRSTUVWXYZ'); }); }); diff --git a/stellar-payment-platform/src/routes/v1/federationRoutes.js b/stellar-payment-platform/src/routes/v1/federationRoutes.js index eea7947..5e0d9a4 100644 --- a/stellar-payment-platform/src/routes/v1/federationRoutes.js +++ b/stellar-payment-platform/src/routes/v1/federationRoutes.js @@ -1,6 +1,7 @@ const express = require('express'); const { prisma } = require('../../../prismaClient'); const { normalizeNameTag, etagCache, USER_DATABASE } = require('../../db'); +const { success, fail, error: jsendError } = require('../../utils/jsend'); const router = express.Router(); @@ -9,9 +10,7 @@ router.get('/federation', etagCache, async (req, res, next) => { const queryValue = typeof q === 'string' ? q.trim() : ''; if (!queryValue) { - const error = new Error("Missing 'q' parameter"); - error.statusCode = 400; - return next(error); + return res.status(400).json(fail({ q: "Missing 'q' parameter" })); } try { @@ -22,9 +21,7 @@ router.get('/federation', etagCache, async (req, res, next) => { }); if (!row) { - const notFoundError = new Error('Address not found'); - notFoundError.statusCode = 404; - return next(notFoundError); + return res.status(404).json(fail({ address: 'Address not found' })); } const response = { @@ -35,7 +32,7 @@ router.get('/federation', etagCache, async (req, res, next) => { response.memo_type = row.memoType; response.memo = row.memo; } - return res.json(response); + return res.json(success(response)); } else if (type === 'name' || !type) { const nameTag = normalizeNameTag(queryValue); const queryName = nameTag.toLowerCase(); @@ -48,9 +45,7 @@ router.get('/federation', etagCache, async (req, res, next) => { const address = row?.address || USER_DATABASE[queryName]; if (!address) { - const notFoundError = new Error('Name tag not found'); - notFoundError.statusCode = 404; - return next(notFoundError); + return res.status(404).json(fail({ name: 'Name tag not found' })); } const response = { @@ -61,16 +56,13 @@ router.get('/federation', etagCache, async (req, res, next) => { response.memo_type = row.memoType; response.memo = row.memo; } - return res.json(response); + return res.json(success(response)); } else { - return res.status(400).json({ - error: "Unsupported query type. Supported types: 'id', 'name'", - }); + return res.status(400).json(fail({ type: "Unsupported query type. Supported types: 'id', 'name'" })); } - } catch { - const dbError = new Error('Database lookup failed'); - dbError.statusCode = 500; - return next(dbError); + } catch (err) { + console.error(err); + return res.status(500).json(jsendError('Database lookup failed')); } }); diff --git a/stellar-payment-platform/src/routes/v1/receiptRoutes.js b/stellar-payment-platform/src/routes/v1/receiptRoutes.js index df542c1..7e9caf1 100644 --- a/stellar-payment-platform/src/routes/v1/receiptRoutes.js +++ b/stellar-payment-platform/src/routes/v1/receiptRoutes.js @@ -1,6 +1,7 @@ const express = require('express'); const { Horizon } = require('@stellar/stellar-sdk'); const PDFDocument = require('pdfkit'); +const { fail, error: jsendError } = require('../../utils/jsend'); const router = express.Router(); @@ -11,7 +12,7 @@ router.get('/receipts/:txHash', async (req, res) => { const { txHash } = req.params; if (!TX_HASH_RE.test(txHash)) { - return res.status(400).json({ detail: 'Invalid transaction hash format' }); + return res.status(400).json(fail({ txHash: 'Invalid transaction hash format' })); } let tx; @@ -25,9 +26,9 @@ router.get('/receipts/:txHash', async (req, res) => { ]); } catch (err) { if (err && err.response && err.response.status === 404) { - return res.status(404).json({ detail: 'Transaction not found' }); + return res.status(404).json(fail({ txHash: 'Transaction not found' })); } - return res.status(500).json({ detail: 'Failed to fetch transaction' }); + return res.status(500).json(jsendError('Failed to fetch transaction')); } const timestamp = tx.created_at diff --git a/stellar-payment-platform/src/routes/v1/userRoutes.js b/stellar-payment-platform/src/routes/v1/userRoutes.js index 969819e..2dec697 100644 --- a/stellar-payment-platform/src/routes/v1/userRoutes.js +++ b/stellar-payment-platform/src/routes/v1/userRoutes.js @@ -4,6 +4,7 @@ const { StrKey } = require('@stellar/stellar-sdk'); const { prisma } = require('../../../prismaClient'); const { verifyMultiSignerThreshold } = require('../../multisigner-verifier'); const { normalizeNameTag, poolGet, poolRun, poolAll } = require('../../db'); +const { success, fail, error: jsendError } = require('../../utils/jsend'); const router = express.Router(); @@ -34,7 +35,7 @@ const validateMemo = (memoType, memo) => { router.post('/register', async (req, res, next) => { if (!req.is('application/json')) { - return res.status(415).json({ error: "Unsupported Media Type. Please send application/json" }); + return res.status(415).json(fail({ contentType: "Unsupported Media Type. Please send application/json" })); } const safeUsername = xss(req.body.username); const username = normalizeNameTag(safeUsername); @@ -44,40 +45,36 @@ router.post('/register', async (req, res, next) => { const signature = typeof req.body.signature === 'string' ? req.body.signature.trim() : ''; if (address.toUpperCase().startsWith('S')) { - return res.status(400).json({ error: "Never share your Secret Key. Please register using your Public Key (starts with G)." }); + return res.status(400).json(fail({ address: "Never share your Secret Key. Please register using your Public Key (starts with G)." })); } if (!username || !address) { - return res.status(400).json({ error: 'Missing required fields: username and address are both required.' }); + return res.status(400).json(fail({ fields: 'Missing required fields: username and address are both required.' })); } const usernameLocalPart = username.includes('*') ? username.split('*')[0] : username; if (usernameLocalPart.length < 3) { - return res.status(400).json({ error: "Username must be at least 3 characters long." }); + return res.status(400).json(fail({ username: "Username must be at least 3 characters long." })); } if (!StrKey.isValidEd25519PublicKey(address)) { - const error = new Error('Invalid Stellar Public Key format.'); - error.statusCode = 400; - return next(error); + return res.status(400).json(fail({ address: 'Invalid Stellar Public Key format.' })); } const memoError = validateMemo(memoType, memo); if (memoError) { - return res.status(400).json({ error: memoError }); + return res.status(400).json(fail({ memo: memoError })); } if (signature && !StrKey.isValidEd25519PublicKey(signature)) { - const error = new Error('Invalid Stellar Public Key format.'); - error.statusCode = 400; - return next(error); + return res.status(400).json(fail({ signature: 'Invalid Stellar Public Key format.' })); } const normalizedUsername = username.toLowerCase(); const RESERVED_NAMES = ['admin', 'root', 'support', 'system', 'stellar', 'api', 'help']; if (RESERVED_NAMES.includes(normalizedUsername)) { - return res.status(403).json({ error: "This username is reserved and cannot be registered." }); + return res.status(403).json(fail({ username: "This username is reserved and cannot be registered." })); } try { @@ -86,9 +83,7 @@ router.post('/register', async (req, res, next) => { }); if (existing) { - const conflictError = new Error('Address already registered'); - conflictError.statusCode = 409; - return next(conflictError); + return res.status(409).json(fail({ address: 'Address already registered' })); } let verificationResult = null; @@ -98,11 +93,7 @@ router.post('/register', async (req, res, next) => { }); if (!verificationResult.success) { - const verificationError = new Error( - verificationResult.errorMessage || 'Signature verification failed' - ); - verificationError.statusCode = 401; - throw verificationError; + return res.status(401).json(fail({ signature: verificationResult.errorMessage || 'Signature verification failed' })); } } @@ -114,8 +105,7 @@ router.post('/register', async (req, res, next) => { }, }); - return res.status(201).json({ - ok: true, + return res.status(201).json(success({ username: normalizedUsername, address, federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, @@ -129,26 +119,18 @@ router.post('/register', async (req, res, next) => { }, }), ...(memoType && { memo_type: memoType, memo }), - }); + })); } catch (error) { if (error.code === 'SQLITE_CONSTRAINT' || (error.message && error.message.includes('UNIQUE'))) { - return res.status(409).json({ error: 'Username is already taken. Please choose another.' }); + return res.status(409).json(fail({ username: 'Username is already taken. Please choose another.' })); } if (error.message && error.message.includes('Account not found')) { - const notFoundError = new Error(`Account not found on Horizon: ${address}`); - notFoundError.statusCode = 404; - return next(notFoundError); - } - - if (error.statusCode === 401) { - return next(error); + return res.status(404).json(fail({ address: `Account not found on Horizon: ${address}` })); } console.error('Registration error:', error.message); - const registrationError = new Error(`Registration verification failed: ${error.message}`); - registrationError.statusCode = 500; - return next(registrationError); + return res.status(500).json(jsendError(`Registration verification failed: ${error.message}`)); } }); @@ -177,7 +159,7 @@ router.get('/lookup', async (req, res, next) => { return next(notFoundError); } - return res.json({ username: row.username, address }); + return res.json(success({ username: row.username, address })); } catch { const dbError = new Error('Database lookup failed'); dbError.statusCode = 500; @@ -214,7 +196,7 @@ router.get('/lookup', async (req, res, next) => { created_at: user.createdAt.toISOString(), })); - return res.json({ data, totalCount, totalPages, currentPage: page }); + return res.json(success({ data, totalCount, totalPages, currentPage: page })); } catch { const dbError = new Error('Database lookup failed'); dbError.statusCode = 500; @@ -255,7 +237,7 @@ router.get('/users', async (req, res, next) => { created_at: user.createdAt.toISOString(), })); - res.json({ data, totalCount, totalPages, currentPage: page }); + res.json(success({ data, totalCount, totalPages, currentPage: page })); } catch { const dbError = new Error('Database error'); dbError.statusCode = 500; diff --git a/stellar-payment-platform/src/utils/jsend.js b/stellar-payment-platform/src/utils/jsend.js new file mode 100644 index 0000000..242721b --- /dev/null +++ b/stellar-payment-platform/src/utils/jsend.js @@ -0,0 +1,5 @@ +const success = (data) => ({ status: 'success', data }); +const fail = (data) => ({ status: 'fail', data }); +const error = (message) => ({ status: 'error', message }); + +module.exports = { success, fail, error }; diff --git a/stellar-payment-platform/tests/api.test.js b/stellar-payment-platform/tests/api.test.js index 4aa0935..dd90c18 100644 --- a/stellar-payment-platform/tests/api.test.js +++ b/stellar-payment-platform/tests/api.test.js @@ -26,8 +26,9 @@ describe('GET /federation', () => { .query({ q: 'client*localhost' }); expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty('stellar_address'); - expect(res.body).toHaveProperty('account_id'); + expect(res.body.status).toBe('success'); + expect(res.body.data).toHaveProperty('stellar_address'); + expect(res.body.data).toHaveProperty('account_id'); }); it('returns 404 for an unknown user', async () => { @@ -36,6 +37,7 @@ describe('GET /federation', () => { .query({ q: 'nonexistentuser*localhost' }); expect(res.statusCode).toBe(404); - expect(res.body).toHaveProperty('error'); + expect(res.body.status).toBe('fail'); + expect(res.body).toHaveProperty('data'); }); }); From d1e2052d6d922f9a5a7c7506f7d51815cf6e291b Mon Sep 17 00:00:00 2001 From: Strategy Dan Date: Wed, 1 Jul 2026 09:26:54 +0100 Subject: [PATCH 2/2] fix: resolve linting error and complete JSend standardization and test updates --- .../src/views/RegistrationPage.jsx | 1 - stellar-payment-platform/server.js | 152 +++++++++++++++--- 2 files changed, 127 insertions(+), 26 deletions(-) diff --git a/payment-dashboard/src/views/RegistrationPage.jsx b/payment-dashboard/src/views/RegistrationPage.jsx index e531956..fd87f4c 100644 --- a/payment-dashboard/src/views/RegistrationPage.jsx +++ b/payment-dashboard/src/views/RegistrationPage.jsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import freighterApi from '@stellar/freighter-api'; -import LoadingSpinner from '../components/LoadingSpinner'; import { API_BASE, normalizeNameTag } from './shared'; const USERNAME_REGEX = /^[a-zA-Z0-9]/; diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index aba11bb..3422be7 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -485,40 +485,142 @@ app.get('/users', async (req, res, next) => { created_at: user.createdAt.toISOString(), })); - res.json({ data, totalCount, totalPages, currentPage: page }); - } catch { - const dbError = new Error('Database error'); - dbError.statusCode = 500; - return next(dbError); + return res.json(success({ data, totalCount, totalPages, currentPage: page })); + } catch (err) { + return next(err); } }); -// Mount v1 router for both legacy paths and explicit API versioning -app.use('/', v1Router); -app.use('/api/v1', v1Router); - -app.get('/.well-known/stellar.toml', (_req, res) => { - res.header("Access-Control-Allow-Origin", "*"); - res.setHeader('Content-Type', 'text/plain'); - res.send('FEDERATION_SERVER="https://stellar-tags-production.up.railway.app/federation"\n'); -}); +// ... +app.post('/register', async (req, res, next) => { + if (!req.is('application/json')) { + return res.status(415).json(fail({ contentType: "Unsupported Media Type. Please send application/json" })); + } + const safeUsername = xss(req.body.username); + const username = normalizeNameTag(safeUsername); + const address = typeof req.body.address === 'string' ? req.body.address.trim() : ''; + const memoType = typeof req.body.memo_type === 'string' ? req.body.memo_type.trim() : undefined; + const memo = typeof req.body.memo === 'string' ? req.body.memo.trim() : undefined; + const signature = typeof req.body.signature === 'string' ? req.body.signature.trim() : ''; -app.get('/api/v1/time', (_req, res) => { - res.status(200).json({ time: new Date().toISOString() }); -}); + if (address.toUpperCase().startsWith('S')) { + return res.status(400).json(fail({ address: "Never share your Secret Key. Please register using your Public Key (starts with G)." })); + } -app.get('/health', (_req, res) => { - res.json({ status: 'ok' }); -}); + if (!username || !address) { + return res.status(400).json(fail({ fields: 'Missing required fields: username and address are both required.' })); + } + + // Extract the username part before the * for profanity check and length validation + const usernameLocalPart = username.includes('*') ? username.split('*')[0] : username; + + if (usernameLocalPart.length < 3) { + return res.status(400).json(fail({ username: "Username must be at least 3 characters long." })); + } + + // Reject usernames containing profanity or offensive words. + if (profanityFilter.isProfane(usernameLocalPart)) { + return res.status(400).json(fail({ username: 'Username contains restricted words' })); + } + + if (!StrKey.isValidEd25519PublicKey(address)) { + const error = new Error('Invalid Stellar Public Key format.'); + error.statusCode = 400; + return next(error); + } -app.use((err, _req, _res, next) => { - if (err.type === 'entity.too.large') { - const error = new Error('Payload too large. Maximum allowed size is 10kb.'); - error.statusCode = 413; + const memoError = validateMemo(memoType, memo); + if (memoError) { + return res.status(400).json(fail({ memo: memoError })); + } + + // Signature is optional for legacy single-signer registrations. + // If provided, validate its format and run multi-signer verification. + if (signature && !StrKey.isValidEd25519PublicKey(signature)) { + const error = new Error('Invalid Stellar Public Key format.'); + error.statusCode = 400; return next(error); } - next(err); + + const normalizedUsername = username.toLowerCase(); + + const RESERVED_NAMES = ['admin', 'root', 'support', 'system', 'stellar', 'api', 'help']; + if (RESERVED_NAMES.includes(normalizedUsername)) { + return res.status(403).json(fail({ username: "This username is reserved and cannot be registered." })); + } + + try { + const existing = await prisma.user.findUnique({ + where: { address } + }); + + if (existing) { + return res.status(409).json(fail({ address: 'Address already registered' })); + } + let verificationResult = null; + if (signature) { + verificationResult = await verifyMultiSignerThreshold(address, [signature], { + operationType: 'management', + }); + + if (!verificationResult.success) { + const verificationError = new Error( + verificationResult.errorMessage || 'Signature verification failed' + ); + verificationError.statusCode = 401; + throw verificationError; + } + } + + await prisma.user.create({ + data: { + username: normalizedUsername, + address, + ...(memoType && { memoType, memo }), + }, + }); + + return res.status(201).json(success({ + username: normalizedUsername, + address, + federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, + ...(verificationResult && { + verification: { + accountId: verificationResult.accountId, + signerCount: verificationResult.signerCount, + thresholdMet: verificationResult.success, + requiredThreshold: verificationResult.requiredThreshold, + providedWeight: verificationResult.totalWeight, + }, + }), + ...(memoType && { memo_type: memoType, memo }), + })); + } catch (error) { + if (error.code === 'SQLITE_CONSTRAINT' || (error.message && error.message.includes('UNIQUE'))) { + return res.status(409).json(fail({ username: 'Username is already taken. Please choose another.' })); + } + + // Handle verification errors + if (error.message && error.message.includes('Account not found')) { + const notFoundError = new Error(`Account not found on Horizon: ${address}`); + notFoundError.statusCode = 404; + return next(notFoundError); + } + + // Handle signature verification errors + if (error.statusCode === 401) { + return next(error); + } + + // Handle other errors + console.error('Registration error:', error.message); + const registrationError = new Error(`Registration verification failed: ${error.message}`); + registrationError.statusCode = 500; + return next(registrationError); + } }); +app.all('/register', (req, res) => res.status(405).json(fail({ method: "Method Not Allowed" }))); + const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS, 10) || 10_000; let isShuttingDown = false;