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
12 changes: 9 additions & 3 deletions backend/src/middleware/auditLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ const AUDIT_LOG_PATH = process.env.AUDIT_LOG_PATH || path.join(__dirname, '../..

/**
* Append a single audit entry to the append-only NDJSON log file.
* This is the only write path — there is no update or delete.
* Uses async write to avoid blocking the request/response cycle.
*/
function writeAuditEntry(entry) {
const line = JSON.stringify(entry) + '\n';
// appendFileSync keeps writes atomic enough for a single-process server
fs.appendFileSync(AUDIT_LOG_PATH, line, 'utf8');
// Non-blocking append — fire and forget to avoid blocking request handlers
fs.appendFile(AUDIT_LOG_PATH, line, 'utf8', (err) => {
if (err) {
// Log to stderr but don't crash the server
console.error(`[AuditLog] Failed to write entry: ${err.message}`);
}
});
}

/**
* Build and persist an audit log entry.
* Non-blocking — does not wait for file write to complete.
*
* @param {object} opts
* @param {string} opts.actor - Stellar public key of the acting wallet
Expand Down
25 changes: 24 additions & 1 deletion backend/src/routes/vaccination.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,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, mintVaccination, sendRpcTimeout, SorobanTimeoutError } = require('../stellar/soroban');
const { invokeContract, simulateContract, mintVaccination, sendRpcTimeout, SorobanTimeoutError, checkDuplicateRecord } = require('../stellar/soroban');
const { resolveContractErrorMessage, mapContractError } = require('../stellar/contractErrors');
const { audit } = require('../middleware/auditLog');
const validate = require('../middleware/validate');
Expand Down Expand Up @@ -63,6 +63,8 @@ const router = express.Router();
* description: Unauthorized
* 403:
* description: Forbidden - issuer role required
* 409:
* description: Duplicate record
* 500:
* description: Contract invocation failed
* content:
Expand All @@ -85,6 +87,27 @@ router.post(
return res.status(403).json({ error: 'Patient has not provided consent. They must consent before a record can be issued.' });
}

// Check for duplicate record before invoking contract
try {
const existingTokenId = await checkDuplicateRecord(patient_address, vaccine_name, date_administered);
if (existingTokenId) {
audit({
actor: req.user.publicKey,
action: 'vaccination.issue',
target: patient_address,
result: 'failure',
meta: { error: 'Duplicate record', existing_token_id: existingTokenId },
});
return res.status(409).json({
error: 'Duplicate record',
existing_token_id: existingTokenId,
});
}
} catch (err) {
// If duplicate check fails, continue to contract invocation
// The contract will also check for duplicates
}

try {
const result = await mintVaccination(
patient_address,
Expand Down
29 changes: 29 additions & 0 deletions backend/src/stellar/soroban.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,34 @@ async function simulateContract(method, args) {
return sim.result?.retval;
}

/**
* Check if a patient already has a vaccination record with the same vaccine and date.
* Returns the token_id if found, null otherwise.
* @param {string} wallet - Patient wallet address
* @param {string} vaccineName - Vaccine name to check
* @param {string} dateAdministered - Date to check
*/
async function checkDuplicateRecord(wallet, vaccineName, dateAdministered) {
try {
const result = await verifyVaccination(wallet);
if (!result.vaccinated || !result.records || result.records.length === 0) {
return null;
}

// Find matching record by vaccine name and date
const duplicate = result.records.find(
(record) =>
record.vaccine_name === vaccineName &&
record.date_administered === dateAdministered
);

return duplicate ? duplicate.token_id : null;
} catch (error) {
// If verification fails, let the contract handle it
return null;
}
}

/**
* Send a 503 RPC timeout response. Use in route catch blocks when err is SorobanTimeoutError.
*/
Expand All @@ -292,6 +320,7 @@ module.exports = {
simulateContract,
mintVaccination,
verifyVaccination,
checkDuplicateRecord,
addIssuer,
revokeIssuer,
sendRpcTimeout,
Expand Down
Loading
Loading