Skip to content

Commit 3a23878

Browse files
authored
Merge pull request #3072 from XRPLF/js-credentials-issuer-tutorial
Tutorial [JS]: Build a Credential Issuer Service
2 parents d322370 + 0af23cb commit 3a23878

File tree

10 files changed

+900
-0
lines changed

10 files changed

+900
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ISSUER_ACCOUNT_SEED=<your-seed-goes-here>
2+
PORT=3005
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Credential Issuing Service - Python sample code
2+
3+
This code implements an HTTP API that issues credentials on the XRPL on request.
4+
5+
Quick install & usage:
6+
7+
```sh
8+
npm install
9+
node issuer_service.js
10+
```
11+
12+
For more detail, see the full tutorial for [How to build a service that issues credentials on the XRP Ledger](https://xrpl.org/docs/tutorials/javascript/build-apps/credential-issuing-service).
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env node
2+
3+
import dotenv from "dotenv";
4+
import inquirer from "inquirer";
5+
import { Client, Wallet } from "xrpl";
6+
import { lookUpCredentials } from "./look_up_credentials.js";
7+
import { hexToString } from "@xrplf/isomorphic/dist/utils/index.js";
8+
9+
const XRPL_SERVER = "wss://s.devnet.rippletest.net:51233"
10+
11+
dotenv.config();
12+
13+
async function initWallet() {
14+
let seed = process.env.SUBJECT_ACCOUNT_SEED;
15+
if (!seed) {
16+
const { seedInput } = await inquirer.prompt([
17+
{
18+
type: "password",
19+
name: "seedInput",
20+
message: "Subject account seed:",
21+
validate: (input) => (input ? true : "Please specify the subject's master seed"),
22+
},
23+
]);
24+
seed = seedInput;
25+
}
26+
27+
return Wallet.fromSeed(seed);
28+
}
29+
30+
async function main() {
31+
const client = new Client(XRPL_SERVER);
32+
await client.connect();
33+
34+
const wallet = await initWallet();
35+
36+
const pendingCredentials = await lookUpCredentials(
37+
client,
38+
"",
39+
wallet.address,
40+
"no"
41+
);
42+
43+
const choices = pendingCredentials.map((cred, i) => ({
44+
name: `${i+1}) '${hexToString(cred.CredentialType)}' issued by ${cred.Issuer}`,
45+
value: i,
46+
}));
47+
choices.unshift({ name: "0) No, quit.", value: -1 });
48+
49+
const { selectedIndex } = await inquirer.prompt([
50+
{
51+
type: "list",
52+
name: "selectedIndex",
53+
message: "Accept a credential?",
54+
choices,
55+
},
56+
]);
57+
58+
if (selectedIndex === -1) {
59+
process.exit(0);
60+
}
61+
62+
const chosenCred = pendingCredentials[selectedIndex];
63+
const tx = {
64+
TransactionType: "CredentialAccept",
65+
Account: wallet.address,
66+
CredentialType: chosenCred.CredentialType,
67+
Issuer: chosenCred.Issuer,
68+
};
69+
70+
console.log("Submitting transaction:", tx);
71+
const response = await client.submit(tx, { autofill: true, wallet });
72+
console.log("Response:", response);
73+
74+
await client.disconnect();
75+
}
76+
77+
main().catch((err) => {
78+
console.error("❌ Error:", err.message);
79+
process.exit(1);
80+
})
81+
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {
2+
isoTimeToRippleTime,
3+
rippleTimeToISOTime,
4+
isValidClassicAddress,
5+
} from "xrpl";
6+
import { stringToHex, hexToString } from "@xrplf/isomorphic/dist/utils/index.js";
7+
8+
import { ValueError } from "./errors.js";
9+
10+
// Regex constants
11+
const CREDENTIAL_REGEX = /^[A-Za-z0-9_.-]{1,128}$/;
12+
const URI_REGEX = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{1,256}$/;
13+
14+
/**
15+
* Validate credential request.
16+
* This function performs parameter validation. Validated fields:
17+
* - subject (required): the subject of the credential, as a classic address
18+
* - credential (required): the credential type, in human-readable (ASCII) chars
19+
* - uri (optional): URI of the credential in human-readable (ASCII) chars
20+
* - expiration (optional): time when the credential expires (displayed as an ISO 8601 format string in JSON)
21+
*/
22+
export function validateCredentialRequest({ subject, credential, uri, expiration }) {
23+
// Validate subject
24+
if (typeof subject !== "string") {
25+
throw new ValueError("Must provide a string 'subject' field");
26+
}
27+
if (!isValidClassicAddress(subject)) {
28+
throw new ValueError(`subject not valid address: '${subject}'`);
29+
}
30+
31+
// Validate credential
32+
if (typeof credential !== "string") {
33+
throw new ValueError("Must provide a string 'credential' field");
34+
}
35+
if (!CREDENTIAL_REGEX.test(credential)) {
36+
/**
37+
* Checks if the specified credential type is one that this service issues.
38+
* XRPL credential types can be any binary data; this service issues
39+
* any credential that can be encoded from the following ASCII chars:
40+
* alphanumeric characters, underscore, period, and dash. (min length 1, max 128)
41+
*
42+
* You might want to further limit the credential types, depending on your
43+
* use case; for example, you might only issue one specific credential type.
44+
*/
45+
throw new ValueError(`credential not allowed: '${credential}'.`);
46+
}
47+
48+
/*
49+
(Optional) Checks if the specified URI is acceptable for this service.
50+
51+
XRPL Credentials' URI values can be any binary data; this service
52+
adds any user-requested URI to a Credential as long as the URI
53+
can be encoded from the characters usually allowed in URIs, namely
54+
the following ASCII chars:
55+
56+
alphanumeric characters (upper and lower case)
57+
the following symbols: -._~:/?#[]@!$&'()*+,;=%
58+
(minimum length 1 and max length 256 chars)
59+
60+
You might want to instead define your own URI and attach it to the
61+
Credential regardless of user input, or you might want to verify that the
62+
URI points to a valid Verifiable Credential document that matches the user.
63+
*/
64+
if (uri !== undefined) {
65+
if (typeof uri !== "string" || !URI_REGEX.test(uri)) {
66+
throw new ValueError(`URI isn't valid: ${uri}`);
67+
}
68+
}
69+
70+
// Validate and parse expiration
71+
let parsedExpiration;
72+
if (expiration !== undefined) {
73+
if (typeof expiration !== "string") {
74+
throw new ValueError(`Unsupported expiration format: ${typeof expiration}`);
75+
}
76+
parsedExpiration = new Date(expiration);
77+
if (isNaN(parsedExpiration.getTime())) {
78+
throw new ValueError(`Invalid expiration date: ${expiration}`);
79+
}
80+
}
81+
82+
return {
83+
subject,
84+
credential,
85+
uri,
86+
expiration: parsedExpiration,
87+
};
88+
}
89+
90+
// Convert an XRPL ledger entry into a usable credential object
91+
export function credentialFromXrpl(entry) {
92+
const { Subject, CredentialType, URI, Expiration, Flags } = entry;
93+
return {
94+
subject: Subject,
95+
credential: hexToString(CredentialType),
96+
uri: URI ? hexToString(URI) : undefined,
97+
expiration: Expiration ? rippleTimeToISOTime(Expiration) : undefined,
98+
accepted: Boolean(Flags & 0x00010000), // lsfAccepted
99+
};
100+
}
101+
102+
// Convert to an object in a format closer to the XRP Ledger representation
103+
export function credentialToXrpl(cred) {
104+
// Credential type and URI are hexadecimal;
105+
// Expiration, if present, is in seconds since the Ripple Epoch.
106+
return {
107+
subject: cred.subject,
108+
credential: stringToHex(cred.credential),
109+
uri: cred.uri ? stringToHex(cred.uri) : undefined,
110+
expiration: cred.expiration
111+
? isoTimeToRippleTime(cred.expiration)
112+
: undefined,
113+
};
114+
}
115+
116+
117+
export function verifyDocuments({ documents }) {
118+
/**
119+
* This is where you would check the user's documents to see if you
120+
* should issue the requested Credential to them.
121+
* Depending on the type of credentials your service needs, you might
122+
* need to implement different types of checks here.
123+
*/
124+
if (typeof documents !== "object" || Object.keys(documents).length === 0) {
125+
throw new ValueError("you must provide a non-empty 'documents' field");
126+
}
127+
128+
// As a placeholder, this example checks that the documents field
129+
// contains a string field named "reason" containing the word "please".
130+
const reason = documents.reason;
131+
if (typeof reason !== "string") {
132+
throw new ValueError("documents must contain a 'reason' string");
133+
}
134+
135+
if (!reason.toLowerCase().includes("please")) {
136+
throw new ValueError("reason must include 'please'");
137+
}
138+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export class ValueError extends Error {
2+
constructor(message) {
3+
super(message);
4+
this.name = "ValueError";
5+
this.status = 400;
6+
this.type = "badRequest";
7+
}
8+
}
9+
10+
export class XRPLTxError extends Error {
11+
constructor(xrplResponse, status = 400) {
12+
super("XRPL transaction failed");
13+
this.name = "XRPLTxError";
14+
this.status = status;
15+
this.body = xrplResponse.result;
16+
}
17+
}

0 commit comments

Comments
 (0)