Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions stellar-payment-platform/register-multisigner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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/);
});
});

Expand Down Expand Up @@ -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');
});
});

Expand All @@ -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 () => {
Expand All @@ -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');
});
});

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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\*/);
});
});

Expand Down Expand Up @@ -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');
});
});
});
180 changes: 136 additions & 44 deletions stellar-payment-platform/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -484,63 +485,143 @@ 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);
}

const memoError = validateMemo(memoType, memo);
if (memoError) {
return res.status(400).json(fail({ memo: memoError }));
}

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;
// 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);
});

// 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';
const normalizedUsername = username.toLowerCase();

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,
});
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." }));
}

return res.status(statusCode).json({
success: false,
error: errorMessage,
statusCode: statusCode,
});
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;

Expand All @@ -567,6 +648,17 @@ const gracefulShutdown = (server, pool, signal) => {
});
};

// Global JSend error handling middleware
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;

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) {
const server = app.listen(PORT, '0.0.0.0', () => {
Expand Down
Loading
Loading