Skip to content
Merged
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
34 changes: 17 additions & 17 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 11 additions & 13 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const patientRoutes = require('./routes/patient');
const consentRoutes = require('./routes/consent');
const onboardingRoutes = require('./routes/onboarding');
const apiVersion = require('./middleware/apiVersion');
const { getRpcServer } = require('./stellar/soroban');
const { getHealthStatus, startHealthProbe } = require('./health');

const requestId = require('./middleware/requestId');
const { sanitizeInputs } = require('./middleware/sanitize');
Expand All @@ -29,13 +29,17 @@ const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(',').map(o => o

app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(null, false);
}
},
credentials: true
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
optionsSuccessStatus: 204,
}));
app.use(securityHeaders);
app.use(express.json({ limit: config.BODY_LIMIT }));
Expand Down Expand Up @@ -87,25 +91,19 @@ app.use(['/auth', '/vaccination', '/verify', '/admin', '/patient', '/events'], (
* Health check endpoint.
*
* @route GET /health
* @returns {Object} 200 - { status: "ok", soroban: true, timestamp }
* @returns {Object} 503 - { status: "degraded", soroban: false, timestamp }
* @returns {Object} 200 - { status: "ok", uptime }
* @returns {Object} 503 - { status: "degraded", uptime }
*/
app.get('/health', async (_req, res) => {
let soroban = false;
try {
await getRpcServer().getHealth();
soroban = true;
} catch (_err) {
// RPC unreachable
}
const body = { status: soroban ? 'ok' : 'degraded', soroban, timestamp: new Date().toISOString() };
res.status(soroban ? 200 : 503).json(body);
const body = getHealthStatus();
res.status(body.status === 'ok' ? 200 : 503).json(body);
});

if (require.main === module) {
initializeSecrets().then(() => {
initDb(config.DATABASE_PATH).then(() => {
startPoller(config.EVENT_POLL_INTERVAL_MS);
startHealthProbe();
const server = app.listen(config.PORT, () => {
logger.info(`Backend running on port ${config.PORT}`);
});
Expand Down
3 changes: 3 additions & 0 deletions backend/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const schema = z.object({
SOROBAN_FEE: z.coerce.number().int().positive().default(100),
SOROBAN_TIP: z.coerce.number().int().min(0).default(0),

// Health probe interval for cached dependency status
HEALTH_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(15000),

// Request limits
BODY_LIMIT: z.string().default('10kb'),

Expand Down
56 changes: 56 additions & 0 deletions backend/src/health.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const config = require('./config');
const { getRpcServer } = require('./stellar/soroban');
const logger = require('./logger');

let sorobanHealthy = true;
let lastProbeAt = null;

function getHealthStatus() {
const status = sorobanHealthy ? 'ok' : 'degraded';
return {
status,
uptime: Number(process.uptime().toFixed(3)),
};
}

async function probeSorobanHealth() {
try {
await getRpcServer().getHealth();
sorobanHealthy = true;
} catch (error) {
sorobanHealthy = false;
logger.warn('Soroban health probe failed', {
message: error.message,
code: error.code,
});
} finally {
lastProbeAt = new Date();
}
}

function startHealthProbe() {
// Run an initial probe immediately, then refresh at the configured interval.
probeSorobanHealth().catch((error) => {
logger.error('Initial Soroban health probe failed', { message: error.message });
});

const interval = setInterval(() => {
probeSorobanHealth().catch((error) => {
logger.error('Soroban health probe failed', { message: error.message });
});
}, config.HEALTH_POLL_INTERVAL_MS);

return () => clearInterval(interval);
}

function setSorobanHealthy(value) {
sorobanHealthy = value;
lastProbeAt = new Date();
}

module.exports = {
getHealthStatus,
startHealthProbe,
setSorobanHealthy,
_getLastProbeAt: () => lastProbeAt,
};
28 changes: 10 additions & 18 deletions backend/src/routes/vaccination.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const StellarSdk = require('@stellar/stellar-sdk');
const authMiddleware = require('../middleware/auth');
const issuerMiddleware = require('../middleware/issuer');
const { validateStellarPublicKey } = require('../middleware/wallet');
const { invokeContract, simulateContract } = require('../stellar/soroban');
const { invokeContract, simulateContract, mintVaccination } = require('../stellar/soroban');
const { resolveContractErrorMessage } = require('../stellar/contractErrors');
const { audit } = require('../middleware/auditLog');
const validate = require('../middleware/validate');
Expand Down Expand Up @@ -106,34 +106,26 @@ router.post(
}

try {
const toOptU32 = (v) => v != null
? StellarSdk.xdr.ScVal.scvVec([StellarSdk.xdr.ScVal.scvU32(v)])
: StellarSdk.xdr.ScVal.scvVoid();

const args = [
StellarSdk.Address.fromString(patient_address).toScVal(),
StellarSdk.xdr.ScVal.scvString(vaccine_name),
StellarSdk.xdr.ScVal.scvString(date_administered),
StellarSdk.Address.fromString(req.user.publicKey).toScVal(),
toOptU32(dose_number),
toOptU32(dose_series),
];

const result = await invokeContract(process.env.ISSUER_SECRET_KEY, 'mint_vaccination', args);
const tokenId = StellarSdk.scValToNative(result.returnValue);
const result = await mintVaccination(
patient_address,
vaccine_name,
date_administered,
process.env.ISSUER_SECRET_KEY,
{ doseNumber: dose_number, doseSeries: dose_series }
);
const timestamp = new Date().toISOString();

audit({
actor: req.user.publicKey,
action: 'vaccination.issue',
target: patient_address,
result: 'success',
meta: { token_id: tokenId, vaccine_name, date_administered, dose_number, dose_series },
meta: { token_id: result.tokenId, vaccine_name, date_administered, dose_number, dose_series },
});

res.json({
success: true,
tokenId,
tokenId: result.tokenId,
transactionHash: result.hash,
ledger: result.ledger,
timestamp,
Expand Down
6 changes: 2 additions & 4 deletions backend/src/routes/verify.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const express = require('express');
const StellarSdk = require('@stellar/stellar-sdk');
const { validateStellarPublicKey } = require('../middleware/wallet');
const { simulateContract } = require('../stellar/soroban');
const { simulateContract, verifyVaccination } = require('../stellar/soroban');
const { resolveContractErrorMessage } = require('../stellar/contractErrors');
const { verifyLimiter, verifierKeyLimiter } = require('../middleware/rateLimiter');
const verifierApiKey = require('../middleware/verifierApiKey');
Expand Down Expand Up @@ -102,9 +102,7 @@ router.get(
}

try {
const args = [StellarSdk.Address.fromString(wallet).toScVal()];
const result = await simulateContract('verify_vaccination', args);
const [vaccinated, records] = StellarSdk.scValToNative(result);
const { vaccinated, records } = await verifyVaccination(wallet);

verifyCache.set(wallet, {
vaccinated,
Expand Down
103 changes: 102 additions & 1 deletion backend/src/stellar/soroban.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@ const {
SOROBAN_RPC_MAX_RETRIES,
} = config;

class SorobanError extends Error {
constructor(message, originalError) {
super(message);
this.name = 'SorobanError';
this.original = originalError;
}
}

class SorobanRpcError extends SorobanError {
constructor(message, originalError) {
super(message, originalError);
this.name = 'SorobanRpcError';
}
}

class SorobanTransactionError extends SorobanError {
constructor(message, originalError) {
super(message, originalError);
this.name = 'SorobanTransactionError';
}
}

class SorobanSimulationError extends SorobanError {
constructor(message, originalError) {
super(message, originalError);
this.name = 'SorobanSimulationError';
}
}

// Fee in stroops (1 XLM = 10_000_000 stroops). Minimum is 100.
const TX_FEE = String(process.env.SOROBAN_FEE || 100);
// Inclusion tip in stroops for priority during congestion (0 = no tip).
Expand Down Expand Up @@ -129,6 +158,67 @@ async function invokeContract(secretKey, method, args) {
return { returnValue: result.returnValue, hash: response.hash, ledger: result.ledger };
}

function toOptionalU32(value) {
return value != null
? StellarSdk.xdr.ScVal.scvVec([StellarSdk.xdr.ScVal.scvU32(value)])
: StellarSdk.xdr.ScVal.scvVoid();
}

/**
* Mint a vaccination record and submit a signed transaction.
* @param {string} patient - Stellar address of the patient
* @param {string} vaccineName - Vaccine name
* @param {string} dateAdministered - ISO-8601 timestamp
* @param {string} issuerSecret - Issuer secret key for signing
* @param {Object} [options]
* @param {number|null} [options.doseNumber]
* @param {number|null} [options.doseSeries]
*/
async function mintVaccination(patient, vaccineName, dateAdministered, issuerSecret, options = {}) {
try {
const issuerKeypair = StellarSdk.Keypair.fromSecret(issuerSecret);
const args = [
StellarSdk.Address.fromString(patient).toScVal(),
StellarSdk.xdr.ScVal.scvString(vaccineName),
StellarSdk.xdr.ScVal.scvString(dateAdministered),
StellarSdk.Address.fromString(issuerKeypair.publicKey()).toScVal(),
toOptionalU32(options.doseNumber),
toOptionalU32(options.doseSeries),
];

const result = await invokeContract(issuerSecret, 'mint_vaccination', args);
const tokenId = StellarSdk.scValToNative(result.returnValue);
return {
tokenId,
hash: result.hash,
ledger: result.ledger,
};
} catch (error) {
if (error instanceof SorobanError) throw error;
throw new SorobanTransactionError('Failed to mint vaccination record', error);
}
}

/**
* Verify vaccination status via a read-only contract call.
* @param {string} wallet - Stellar wallet address to verify
*/
async function verifyVaccination(wallet) {
try {
const args = [StellarSdk.Address.fromString(wallet).toScVal()];
const rawResult = await simulateContract('verify_vaccination', args);
const [vaccinated, records] = StellarSdk.scValToNative(rawResult);
return {
wallet,
vaccinated,
records: Array.isArray(records) ? records : [],
};
} catch (error) {
if (error instanceof SorobanError) throw error;
throw new SorobanSimulationError('Failed to verify vaccination status', error);
}
}

/**
* Read-only contract call (no signing needed).
*/
Expand Down Expand Up @@ -158,7 +248,18 @@ async function simulateContract(method, args) {
return sim.result?.retval;
}

module.exports = { getRpcServer, invokeContract, simulateContract, addIssuer };
module.exports = {
getRpcServer,
invokeContract,
simulateContract,
mintVaccination,
verifyVaccination,
addIssuer,
SorobanError,
SorobanRpcError,
SorobanTransactionError,
SorobanSimulationError,
};

/**
* Add a new issuer to the contract allowlist (admin-signed).
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/app.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
const request = require('supertest');
const app = require('../src/app');
const health = require('../src/health');

describe('Health check', () => {
it('GET /health returns ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(typeof res.body.uptime).toBe('number');
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
});

it('GET /health returns degraded when Soroban is unhealthy', async () => {
health.setSorobanHealthy(false);
const res = await request(app).get('/health');
expect(res.status).toBe(503);
expect(res.body.status).toBe('degraded');
expect(typeof res.body.uptime).toBe('number');
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
health.setSorobanHealthy(true);
});
});

Expand Down
Loading