mirror of
https://github.com/XRPLF/xrpl-dev-portal.git
synced 2025-11-04 11:55:50 +00:00
Javascript credential issuing service
This commit is contained in:
2
_code-samples/issue-credentials/js/.env
Normal file
2
_code-samples/issue-credentials/js/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
ISSUER_ACCOUNT_SEED="<your_issuer_account_seed>"
|
||||
PORT=3005
|
||||
80
_code-samples/issue-credentials/js/accept_credential.js
Normal file
80
_code-samples/issue-credentials/js/accept_credential.js
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import inquirer from "inquirer";
|
||||
import { Client, Wallet } from "xrpl";
|
||||
import { lookUpCredentials } from "./look_up_credentials.js";
|
||||
import { decodeHex } from "./utils.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function initWallet() {
|
||||
let seed = process.env.SUBJECT_ACCOUNT_SEED;
|
||||
if (!seed) {
|
||||
const { seedInput } = await inquirer.prompt([
|
||||
{
|
||||
type: "password",
|
||||
name: "seedInput",
|
||||
message: "Subject account seed:",
|
||||
validate: (input) => (input ? true : "Please specify the subject's master seed"),
|
||||
},
|
||||
]);
|
||||
|
||||
seed = seedInput;
|
||||
}
|
||||
|
||||
return Wallet.fromSeed(seed);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const XRPL_SERVER = "wss://s.devnet.rippletest.net:51233"
|
||||
const client = new Client(XRPL_SERVER);
|
||||
await client.connect();
|
||||
|
||||
const wallet = await initWallet();
|
||||
|
||||
const pendingCredentials = await lookUpCredentials(client, "", wallet.address, "no");
|
||||
if (pendingCredentials.length === 0) {
|
||||
console.log("No pending credentials to accept");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const choices = pendingCredentials.map((cred, i) => ({
|
||||
name: `${i+1})'${decodeHex(cred.CredentialType)}' issued by ${cred.Issuer}`,
|
||||
value: i,
|
||||
}));
|
||||
choices.unshift({ name: "0) No, quit.", value: -1 });
|
||||
|
||||
const { selectedIndex } = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "selectedIndex",
|
||||
message: "Accept a credential?",
|
||||
choices,
|
||||
},
|
||||
]);
|
||||
|
||||
if (selectedIndex === -1) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const chosenCred = pendingCredentials[selectedIndex];
|
||||
const tx = {
|
||||
TransactionType: "CredentialAccept",
|
||||
Account: wallet.address,
|
||||
CredentialType: chosenCred.CredentialType,
|
||||
Issuer: chosenCred.Issuer,
|
||||
};
|
||||
|
||||
console.log("Submitting transaction:", tx);
|
||||
const response = await client.submit(tx, { autofill: true, wallet });
|
||||
console.log("Response:", response);
|
||||
|
||||
await client.disconnect();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Error:", err.message);
|
||||
process.exit(1);
|
||||
})
|
||||
|
||||
172
_code-samples/issue-credentials/js/credential.js
Normal file
172
_code-samples/issue-credentials/js/credential.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
strToHex,
|
||||
decodeHex,
|
||||
datetimeToRippleTime,
|
||||
rippleTimeToDatetime,
|
||||
} from "./utils.js";
|
||||
|
||||
import { isValidClassicAddress } from "xrpl";
|
||||
|
||||
import { ValueError } from "./errors.js";
|
||||
|
||||
// Regex constants
|
||||
const CREDENTIAL_REGEX = /^[A-Za-z0-9_.-]{1,64}$/;
|
||||
const URI_REGEX = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{1,256}$/;
|
||||
|
||||
/**
|
||||
* Validate credential request.
|
||||
*
|
||||
* This function performs parameter validation. Validated fields:
|
||||
* - subject (required): the subject of the credential, as a classic address
|
||||
* - credential (required): the credential type, in human-readable (ASCII) chars
|
||||
* - uri (optional): URI of the credential in human-readable (ASCII) chars
|
||||
* - expiration (optional): time when the credential expires (displayed as an ISO 8601 format string in JSON)
|
||||
*/
|
||||
export function validateCredentialRequest({ subject, credential, uri, expiration }) {
|
||||
// Validate subject
|
||||
if (typeof subject !== "string") {
|
||||
throw new ValueError("Must provide a string 'subject' field");
|
||||
}
|
||||
if (!isValidClassicAddress(subject)) {
|
||||
throw new ValueError(`subject not valid address: '${subject}'`);
|
||||
}
|
||||
|
||||
// Validate credential
|
||||
if (typeof credential !== "string") {
|
||||
throw new ValueError("Must provide a string 'credential' field");
|
||||
}
|
||||
|
||||
/*
|
||||
Checks if the specified credential type is one that this service issues.
|
||||
|
||||
XRPL credential types can be any binary data; this service issues
|
||||
any credential that can be encoded from the following ASCII chars:
|
||||
alphanumeric characters, underscore, period, and dash.
|
||||
(min length 1, max 64)
|
||||
|
||||
You might want to further limit the credential types, depending on your
|
||||
use case; for example, you might only issue one specific credential type.
|
||||
*/
|
||||
if (!CREDENTIAL_REGEX.test(credential)) {
|
||||
throw new ValueError(`credential not allowed: '${credential}'.`);
|
||||
}
|
||||
|
||||
/*
|
||||
(Optional) Checks if the specified URI is acceptable for this service.
|
||||
|
||||
XRPL Credentials' URI values can be any binary data; this service
|
||||
adds any user-requested URI to a Credential as long as the URI
|
||||
can be encoded from the characters usually allowed in URIs, namely
|
||||
the following ASCII chars:
|
||||
|
||||
alphanumeric characters (upper and lower case)
|
||||
the following symbols: -._~:/?#[]@!$&'()*+,;=%
|
||||
(minimum length 1 and max length 256 chars)
|
||||
|
||||
You might want to instead define your own URI and attach it to the
|
||||
Credential regardless of user input, or you might want to verify that the
|
||||
URI points to a valid Verifiable Credential document that matches the user.
|
||||
*/
|
||||
if (uri !== undefined) {
|
||||
if (typeof uri !== "string" || !URI_REGEX.test(uri)) {
|
||||
throw new ValueError(`URI isn't valid: ${uri}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and parse expiration
|
||||
let parsedExpiration;
|
||||
if (expiration !== undefined) {
|
||||
if (typeof expiration === "string") {
|
||||
parsedExpiration = new Date(expiration);
|
||||
} else {
|
||||
throw new ValueError(`Unsupported expiration format: ${typeof expiration}`);
|
||||
}
|
||||
|
||||
if (isNaN(parsedExpiration.getTime())) {
|
||||
throw new ValueError(`Invalid expiration date: ${expiration}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subject,
|
||||
credential,
|
||||
uri,
|
||||
expiration: parsedExpiration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* As a credential issuer, you typically need to verify some information
|
||||
* about someone before you issue them a credential. For this example,
|
||||
* the user passes relevant information in a documents field of the API request.
|
||||
* The documents are kept confidential, off-chain.
|
||||
*/
|
||||
export function verifyDocuments({documents}) {
|
||||
/*
|
||||
This is where you would check the user's documents to see if you
|
||||
should issue the requested Credential to them.
|
||||
Depending on the type of credentials your service needs, you might
|
||||
need to implement different types of checks here.
|
||||
*/
|
||||
if (typeof documents !== 'object' || Object.keys(documents).length === 0) {
|
||||
throw new ValueError("you must provide a non-empty 'documents' field");
|
||||
}
|
||||
|
||||
// As a placeholder, this example checks that the documents field
|
||||
// contains a string field named "reason" containing the word "please".
|
||||
const reason = documents.reason;
|
||||
if (typeof reason !== "string") {
|
||||
throw new ValueError("documents must contain a 'reason' string");
|
||||
}
|
||||
|
||||
if (!reason.toLowerCase().includes("please")) {
|
||||
throw new ValueError("reason must include 'please'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to a Credential object in a format closer to the XRP Ledger representation.
|
||||
* Credential type and URI are hexadecimal;
|
||||
* Expiration, if present, is in seconds since the Ripple Epoch.
|
||||
*/
|
||||
export function credentialToXrpl(cred) {
|
||||
return {
|
||||
subject: cred.subject,
|
||||
credential: strToHex(cred.credential),
|
||||
uri: cred.uri ? strToHex(cred.uri) : undefined,
|
||||
expiration: cred.expiration ? datetimeToRippleTime(cred.expiration) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert an XRPL ledger entry into a usable credential object
|
||||
export function parseCredentialFromXrpl(entry) {
|
||||
const { Subject, CredentialType, URI, Expiration, Flags } = entry;
|
||||
|
||||
if (!Subject || !CredentialType) {
|
||||
throw new Error("Missing required fields from XRPL credential entry");
|
||||
}
|
||||
|
||||
return {
|
||||
subject: Subject,
|
||||
credential: decodeHex(CredentialType),
|
||||
uri: URI ? decodeHex(URI) : undefined,
|
||||
expiration: Expiration ? rippleTimeToDatetime(Expiration) : undefined,
|
||||
accepted: Boolean(Flags & 0x00010000), // lsfAccepted
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a credential object into API-friendly JSON
|
||||
*/
|
||||
export function serializeCredential(cred) {
|
||||
const result = {
|
||||
subject: cred.subject,
|
||||
credential: cred.credential,
|
||||
};
|
||||
|
||||
if (cred.uri) result.uri = cred.uri;
|
||||
if (cred.expiration) result.expiration = cred.expiration.toISOString();
|
||||
if (cred.accepted !== undefined) result.accepted = cred.accepted;
|
||||
|
||||
return result;
|
||||
}
|
||||
17
_code-samples/issue-credentials/js/errors.js
Normal file
17
_code-samples/issue-credentials/js/errors.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export class ValueError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ValueError";
|
||||
this.status = 400;
|
||||
this.type = "badRequest";
|
||||
}
|
||||
}
|
||||
|
||||
export class XRPLTxError extends Error {
|
||||
constructor(xrplResponse, status = 400) {
|
||||
super("XRPL transaction failed");
|
||||
this.name = "XRPLTxError";
|
||||
this.status = status;
|
||||
this.body = xrplResponse.result;
|
||||
}
|
||||
}
|
||||
175
_code-samples/issue-credentials/js/issuer_service.js
Normal file
175
_code-samples/issue-credentials/js/issuer_service.js
Normal file
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
});
|
||||
52
_code-samples/issue-credentials/js/look_up_credentials.js
Normal file
52
_code-samples/issue-credentials/js/look_up_credentials.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ValueError } from "./errors.js";
|
||||
|
||||
const lsfAccepted = 0x00010000;
|
||||
|
||||
/**
|
||||
* Looks up Credentials issued by/to a specified XRPL account, optionally
|
||||
* filtering by accepted status. Handles pagination.
|
||||
*/
|
||||
export async function lookUpCredentials(client, issuer, subject, accepted = "both") {
|
||||
const account = issuer || subject;
|
||||
if (!account) {
|
||||
throw new ValueError("Must specify issuer or subject");
|
||||
}
|
||||
|
||||
accepted = accepted.toLowerCase();
|
||||
if (!["yes", "no", "both"].includes(accepted)) {
|
||||
throw new ValueError("accepted must be 'yes', 'no', or 'both'");
|
||||
}
|
||||
|
||||
const credentials = [];
|
||||
let request = {
|
||||
command: "account_objects",
|
||||
account,
|
||||
type: "credential",
|
||||
};
|
||||
|
||||
// Fetch first page
|
||||
let response = await client.request(request);
|
||||
|
||||
while (true) {
|
||||
for (const obj of response.result.account_objects) {
|
||||
if (issuer && obj.Issuer !== issuer) continue;
|
||||
if (subject && obj.Subject !== subject) continue;
|
||||
|
||||
const credAccepted = Boolean(obj.Flags & lsfAccepted);
|
||||
if (accepted === "yes" && !credAccepted) continue;
|
||||
if (accepted === "no" && credAccepted) continue;
|
||||
|
||||
credentials.push(obj);
|
||||
}
|
||||
|
||||
if (!response.result.marker) break;
|
||||
|
||||
/**
|
||||
* If there is marker, request the next page using the convenience function "requestNextPage()".
|
||||
* See https://js.xrpl.org/classes/Client.html#requestnextpage to learn more.
|
||||
**/
|
||||
response = await client.requestNextPage(request, response.result);
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
20
_code-samples/issue-credentials/js/package.json
Normal file
20
_code-samples/issue-credentials/js/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "issuer_service",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"inquirer": "^12.5.2",
|
||||
"morgan": "^1.10.0",
|
||||
"xrpl": "^4.2.0"
|
||||
}
|
||||
}
|
||||
44
_code-samples/issue-credentials/js/utils.js
Normal file
44
_code-samples/issue-credentials/js/utils.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Validate credential type (1–64 ASCII alphanum, underscore, dash, dot)
|
||||
export function isAllowedCredentialType(credential) {
|
||||
const regex = /^[A-Za-z0-9_.-]{1,64}$/;
|
||||
return regex.test(credential);
|
||||
}
|
||||
|
||||
// Validate URI (1–256 characters, URL-safe)
|
||||
export function isAllowedUri(uri) {
|
||||
const regex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{1,256}$/;
|
||||
return regex.test(uri);
|
||||
}
|
||||
|
||||
// String → hex (uppercase)
|
||||
export function strToHex(str) {
|
||||
return Buffer.from(str, "utf8").toString("hex").toUpperCase();
|
||||
}
|
||||
|
||||
export function decodeHex(sHex) {
|
||||
/**
|
||||
* Try decoding a hex string as ASCII; return the decoded string on success,
|
||||
* or the un-decoded string prefixed by '(BIN) ' on failure.
|
||||
*/
|
||||
try {
|
||||
const buffer = Buffer.from(sHex, "hex");
|
||||
return buffer.toString("ascii");
|
||||
// Could use utf-8 instead, but it has more edge cases.
|
||||
// Optionally, sanitize the string for display before returning
|
||||
} catch (err) {
|
||||
return "(BIN) " + sHex;
|
||||
}
|
||||
}
|
||||
|
||||
// JS Date → Ripple epoch seconds
|
||||
export function datetimeToRippleTime(date) {
|
||||
const rippleEpoch = 946684800; // 2000-01-01T00:00:00Z
|
||||
const seconds = Math.floor(date.getTime() / 1000);
|
||||
return seconds - rippleEpoch;
|
||||
}
|
||||
|
||||
// Ripple epoch seconds → JS Date
|
||||
export function rippleTimeToDatetime(rippleTime) {
|
||||
const rippleEpoch = 946684800;
|
||||
return new Date((rippleTime + rippleEpoch) * 1000);
|
||||
}
|
||||
Reference in New Issue
Block a user