Files
xrpl-dev-portal/_code-samples/issue-credentials/js/issuer_service.js
2025-04-15 11:44:21 +01:00

176 lines
5.7 KiB
JavaScript

import express from "express";
import morgan from 'morgan';
import inquirer from "inquirer";
import dotenv from "dotenv";
import { Wallet, Client, RippledError } from "xrpl";
import {
validateCredentialRequest,
verifyDocuments,
credentialToXrpl,
serializeCredential,
parseCredentialFromXrpl,
} from "./credential.js";
import { XRPLTxError } from "./errors.js";
import { lookUpCredentials } from "./look_up_credentials.js";
dotenv.config();
async function main() {
// Set up XRPL connection ------------------------------------------------------
const wallet = await initWallet();
console.log("✅ Starting credential issuer with XRPL address", wallet.address);
const client = new Client("wss://s.devnet.rippletest.net:51233");
await client.connect();
// Define Express app ------------------------------------------------------
const app = express();
app.use(morgan('common')) // Logger
app.use(express.json()); // Middleware to parse JSON requests
// POST /credential - Method for users to request a credential from the service -------------------
app.post("/credential", async (req, res) => {
try {
// validateCredentialRequest() throws if the request is not validly formatted
const credRequest = validateCredentialRequest(req.body);
// verifyDocuments() throws if the provided documents don't pass inspection
verifyDocuments(req.body);
const credXrpl = credentialToXrpl(credRequest);
const tx = {
TransactionType: "CredentialCreate",
Account: wallet.address,
Subject: credXrpl.subject,
CredentialType: credXrpl.credential,
URI: credXrpl.uri,
Expiration: credXrpl.expiration,
};
const ccResponse = await client.submit(tx, { autofill: true, wallet });
if (ccResponse.result.engine_result === "tecDUPLICATE") {
throw new XRPLTxError(ccResponse, 409);
} else if (ccResponse.result.engine_result !== "tesSUCCESS") {
throw new XRPLTxError(ccResponse);
}
return res.status(201).json(ccResponse.result);
} catch (err) {
return handleAppError(res, err);
}
});
// GET /admin/credential - Method for admins to look up all credentials issued -------------------
app.get("/admin/credential", async (req, res) => {
try {
// ?accepted=yes|no|both query parameter - the default is "both"
const query = Object.fromEntries(
Object.entries(req.query).map(([k, v]) => [k.toLowerCase(), v])
);
const filterAccepted = (query.accepted || "both").toLowerCase();
const credentials = await lookUpCredentials(client, wallet.address, "", filterAccepted);
const result = credentials.map((entry) =>
serializeCredential(parseCredentialFromXrpl(entry))
);
return res.status(200).json({ credentials: result });
} catch (err) {
return handleAppError(res, err);
}
});
// DELETE /admin/credential - Method for admins to revoke an issued credential ----------------------------
app.delete("/admin/credential", async (req, res) => {
let delRequest;
try {
delRequest = validateCredentialRequest(req.body);
const { credential } = credentialToXrpl(delRequest);
// To save on transaction fees, check if the credential exists on ledger before attempting to delete it.
// If the credential is not found, a RippledError (`entryNotFound`) is thrown.
await client.request({
command: "ledger_entry",
credential: {
subject: delRequest.subject,
issuer: wallet.address,
credential_type: credential,
},
});
const tx = {
TransactionType: "CredentialDelete",
Account: wallet.address,
Subject: delRequest.subject,
CredentialType: credential,
};
const cdResponse = await client.submit(tx, { autofill: true, wallet });
if (cdResponse.result.engine_result === "tecNO_ENTRY") {
// Usually this won't happen since we just checked for the credential,
// but it's possible it got deleted since then.
throw new XRPLTxError(cdResponse, 404);
} else if (cdResponse.result.engine_result !== "tesSUCCESS") {
throw new XRPLTxError(cdResponse);
}
return res.status(200).json(cdResponse.result);
} catch (err) {
if (err instanceof RippledError) {
return res.status(404).json({
error: err.data.error,
error_message: `Credential doesn't exist for subject '${delRequest.subject}' and credential type '${delRequest.credential}'`,
});
} else {
return handleAppError(res, err);
}
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🔐 Credential issuer service running on port: ${PORT}`);
});
}
async function initWallet() {
let seed = process.env.ISSUER_ACCOUNT_SEED;
if (!seed) {
const { seedInput } = await inquirer.prompt([
{
type: "password",
name: "seedInput",
message: "Issuer account seed:",
validate: (input) => (input ? true : "Please specify the issuer's master seed"),
},
]);
seed = seedInput;
}
return Wallet.fromSeed(seed);
}
// Error handling --------------------------------------------------------------
function handleAppError(res, err) {
if (err.name === "ValueError") {
return res.status(err.status).json({
error: err.type,
error_message: err.message,
});
}
if (err.name === "XRPLTxError") {
return res.status(err.status).json(err.body);
}
// Default fallback
return res.status(400).json({ error_message: err.message });
}
// Start the server --------------------------------------------------------------
main().catch((err) => {
console.error("❌ Fatal startup error:", err);
process.exit(1);
});