Two small bots.
See who you’re talking to — and gate by trust.
Two open, single-file bots for your Discord server, each a copy-paste away. The card bot answers /maskid @someone with a member’s public profile card. The roles bot lets members /verify once and hands them a role by their Trust Index tier. Public data only for the first; member-granted, pairwise data for the second.
The card bot — /maskid
Anyone asks about a Mask.ID member; the bot posts their public profile card in the channel. Reads what any visitor sees. Stores nothing. Shows a profile — doesn’t prove who in your chat owns it.
Bot 2 · RolesThe roles bot — /verify
Members prove themselves to Mask.ID with their own key and receive a Discord role by Trust Index tier — optionally a Verified Human role too. Verified, never named: the server learns a tier, not a username.
Bot 1 · The card bot
What it does
Someone types /maskid username:@eric_stanek. The bot fetches that member’s public profile card from Mask.ID and replies with it as an embed, linked to the full profile. If there is no public profile under that name, it says so. That is the whole bot — about 60 lines, one dependency, no database.
It reads exactly what any visitor to Mask.ID can see. It never asks a member to share anything, never stores who asked about whom, and needs no Mask.ID account or API key of its own. Roles by Trust Index tier — the version that does ask the member to verify — is Bot 2, below.
Setup
- Create the Discord applicationOpen the Discord Developer Portal → New Application. Under Bot, click Reset Token and copy the token (you see it once). Note the Application ID from General Information. No privileged intents are needed.
- Invite it to your serverUnder OAuth2 → URL Generator tick the scopes
botandapplications.commands, and the bot permissions Send Messages and Embed Links. Open the generated URL and pick your server. - Get the codeDownload the files below into a folder (say
maskid-bot/) and runnpm install. Node 18 or newer. - ConfigureCopy
env.exampleto.envand fill in the token and application ID. SetDISCORD_GUILD_IDto your server’s ID while testing — the command appears instantly instead of within the hour. - Register the command, then start
npm run registeronce, thennpm start. Type/maskidin any channel the bot can see. To keep it running on a server, use the systemd unit at the bottom.
Node loads .env only when you ask: run with node --env-file=.env bot.js (Node 20+), export the variables in your shell, or use the systemd unit, which reads the file for you.
The code
// Mask.ID Discord bot — answers `/maskid username:@someone` with that
// member's profile card. Reads only PUBLIC data (the same card any
// visitor sees at Mask.ID); nothing is stored.
import { Client, EmbedBuilder, GatewayIntentBits } from "discord.js";
const MASKID = process.env.MASKID_HOST ?? "https://app.mask.id";
const token = process.env.DISCORD_TOKEN;
if (!token) { console.error("Set DISCORD_TOKEN."); process.exit(1); }
// Usernames are letters, digits, and underscores. Anything else is rejected
// before it can reach a URL.
const USERNAME = /^[A-Za-z0-9_]{1,32}$/;
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once("ready", (c) => console.log(`Signed in as ${c.user.tag}`));
client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== "maskid") return;
const raw = interaction.options.getString("username", true).trim();
const username = raw.replace(/^@/, "");
if (!USERNAME.test(username)) {
return interaction.reply({ content: "That doesn't look like a Mask.ID username.", ephemeral: true });
}
// The card is rendered on demand and cached server-side; give it a moment.
await interaction.deferReply();
const cardUrl = `${MASKID}/card/${username}`;
const profileUrl = `${MASKID}/u/${username}`;
let status;
try {
const res = await fetch(cardUrl, { method: "HEAD", redirect: "follow" });
status = res.status;
} catch (err) {
console.error("card fetch failed:", err);
return interaction.editReply("Couldn't reach Mask.ID just now. Try again in a minute.");
}
if (status === 404) {
return interaction.editReply(`No public Mask.ID profile for **@${username}**.`);
}
if (status !== 200) {
return interaction.editReply("Mask.ID is busy rendering cards — try again shortly.");
}
const embed = new EmbedBuilder()
.setTitle(`@${username} on Mask.ID`)
.setURL(profileUrl)
.setImage(cardUrl)
.setColor(0x5b6bff)
.setFooter({ text: "Public profile · verified by the member, not about the member" });
return interaction.editReply({ embeds: [embed] });
});
client.login(token);
// Registers the /maskid slash command with Discord. Run once (and again
// whenever you change the command definition): `npm run register`.
import { REST, Routes, SlashCommandBuilder } from "discord.js";
const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID } = process.env;
if (!DISCORD_TOKEN || !DISCORD_CLIENT_ID) {
console.error("Set DISCORD_TOKEN and DISCORD_CLIENT_ID first.");
process.exit(1);
}
const command = new SlashCommandBuilder()
.setName("maskid")
.setDescription("Show a Mask.ID member's profile card")
.addStringOption((o) =>
o.setName("username")
.setDescription("Their Mask.ID username, e.g. @eric_stanek")
.setRequired(true)
)
.toJSON();
const rest = new REST().setToken(DISCORD_TOKEN);
// With DISCORD_GUILD_ID set, the command appears in that server instantly —
// ideal while testing. Without it, it registers globally (up to an hour).
const route = DISCORD_GUILD_ID
? Routes.applicationGuildCommands(DISCORD_CLIENT_ID, DISCORD_GUILD_ID)
: Routes.applicationCommands(DISCORD_CLIENT_ID);
await rest.put(route, { body: [command] });
console.log(`Registered /maskid ${DISCORD_GUILD_ID ? "in guild " + DISCORD_GUILD_ID : "globally"}.`);
{
"name": "maskid-discord-bot",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Answers /maskid <username> with the member's Mask.ID profile card.",
"scripts": {
"register": "node register-commands.js",
"start": "node bot.js"
},
"dependencies": {
"discord.js": "^14.16.3"
},
"engines": { "node": ">=18" }
}
# Copy to .env and fill in. Never commit .env.
DISCORD_TOKEN=paste-the-bot-token
DISCORD_CLIENT_ID=paste-the-application-id
# Optional: register the command in one server only (instant) while testing.
DISCORD_GUILD_ID=
# /etc/systemd/system/maskid-bot.service — keeps the bot running.
[Unit]
Description=Mask.ID Discord bot
After=network-online.target
[Service]
User=maskidbot
WorkingDirectory=/opt/maskid-bot
EnvironmentFile=/opt/maskid-bot/.env
ExecStart=/usr/bin/node bot.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
How it behaves
Rate limits. Card rendering on Mask.ID is limited per IP address, and the bot fetches every card from one address. A busy server with many lookups a minute will see “busy rendering” replies now and then; cards are cached for a while once rendered, so repeat lookups are cheap.
Privacy. The bot sends Mask.ID only the username that was asked about. It does not send who asked, which server, or anything else — and Mask.ID does not log IP addresses. Discord itself fetches the image to display the embed.
A profile, not a proof. The card shows what a Mask.ID profile says about itself — not that the person in your chat owns it. Anyone can claim to be @eric_stanek; the card bot can’t check. When it matters that a Discord account really is an established member, that is what Bot 2 is for: it verifies with the member’s own key and tells the server their tier, without ever naming them.
License. The bot code on this page is public domain (CC0) — copy, change, and ship it however you like.
Bot 2 · The roles bot
What it does
The card bot tells a channel who someone is. This one asks members to verify through Mask.ID once, then gives them a Discord role by their Trust Index tier — so you can gate channels, mute newcomers below a tier, or simply mark the established. It uses the Attestation API.
A member types /verify. The bot replies privately with a Mask.ID link; they approve it there with their key. Mask.ID answers the bot with a signed attestation carrying only the Trust Index and its tier — trusted, building, low, or unproven, Mask.ID’s own bands, so your roles follow Mask.ID’s thresholds rather than numbers you hard-code. The bot checks the signature, sets the role, and re-checks every member daily: a tier that moves gets the new role; a member who revokes on Mask.ID loses the role on the next check.
What it stores. One line per verified member: their Discord id and their pairwise Mask.ID subject id — an identifier minted for your server alone, useless anywhere else and recoverable to nothing. Not their username, not their profile. Unlike the card bot, this one keeps that file (verified.json) so it can re-check tiers.
Optional: a Verified Human role. Set ROLE_HUMAN and the bot also asks members for Mask.ID’s humanity pack — a 0–100 confidence that a real person runs the profile, from what their referrers witnessed on video, by voice, or in person, not from a captcha. The role is granted at HUMAN_MIN or above (default 70). For many servers this is the more useful role: “no bots in #general” is the actual ask, and it says nothing about how trusted someone is, only that there is a person there. Members may decline the pack at consent; they just don’t get that role.
What it needs that the card bot doesn’t. Mask.ID has to reach the bot to deliver attestations, so it listens on a port and you give it a public https address (PUBLIC_URL) — a Caddy or nginx line in front of it, or a tunnel such as cloudflared while you try it out. Localhost and private addresses are refused by design.
Setup
- Create the roles in DiscordServer Settings → Roles: one role per tier you care about (for example Verified · Trusted and Verified · Building), and optionally a Verified Human role. Copy each role’s id. The bot’s own role must sit above them in the list, or Discord won’t let it assign them.
- Create the application and invite itSame as the card bot above, plus the bot permission Manage Roles. Reuse the same application if you like — one bot user can run both scripts.
- Get the codeDownload the files below into a folder (say
maskid-roles/) and runnpm install. Node 18 or newer. - ConfigureCopy
env.exampleto.env: token, application id, server id, yourPUBLIC_URL, and a role id per tier. Leave a tier blank to give it no role. - Register, then start
npm run registeronce, thennpm start. Type/verifyin any channel. To keep it running, use the systemd unit below.
The code
// Mask.ID Discord roles bot — members run `/verify` once, prove themselves
// on Mask.ID, and receive a Discord role by their Trust Index tier. Tiers
// are re-checked daily; a member who revokes loses the role on the next
// check. Stores ONE thing per member: their pairwise Mask.ID subject id,
// which is meaningless anywhere but this server.
import http from "node:http";
import crypto from "node:crypto";
import fs from "node:fs";
import { Client, EmbedBuilder, GatewayIntentBits } from "discord.js";
const env = process.env;
const MASKID = env.MASKID_HOST ?? "https://app.mask.id";
const PUBLIC_URL = env.PUBLIC_URL?.replace(/\/$/, "");
const PORT = Number(env.PORT ?? 8787);
const GUILD_ID = env.DISCORD_GUILD_ID;
const STORE = env.STORE_FILE ?? "./verified.json";
const SYNC_HOURS = Number(env.SYNC_HOURS ?? 24);
for (const k of ["DISCORD_TOKEN", "DISCORD_GUILD_ID", "PUBLIC_URL"]) {
if (!env[k]) { console.error(`Set ${k}.`); process.exit(1); }
}
// Tier → role id. Leave a tier blank to give it no role. The tier names are
// Mask.ID's own bands (trust_index_tier in every attestation), so this bot
// follows Mask.ID's thresholds instead of hard-coding numbers.
const TIER_ROLES = {
trusted: env.ROLE_TRUSTED, building: env.ROLE_BUILDING,
low: env.ROLE_LOW, unproven: env.ROLE_UNPROVEN,
};
const ALL_ROLES = Object.values(TIER_ROLES).filter(Boolean);
// Optional "Verified Human" role: set ROLE_HUMAN and the bot also asks for
// Mask.ID's `humanity` pack — a 0–100 confidence that a real person runs
// the profile, from what their referrers witnessed (video, voice, in
// person), not a captcha. Granted at HUMAN_MIN or above. The member may
// decline that pack at consent; then they simply don't get this role.
const ROLE_HUMAN = env.ROLE_HUMAN;
const HUMAN_MIN = Number(env.HUMAN_MIN ?? 70);
const SCOPES = ["trust_index", ...(ROLE_HUMAN ? ["humanity"] : [])];
/* ---------- Storage: discord user id → { subject_id, tier, at } ---------- */
let verified = {};
try { verified = JSON.parse(fs.readFileSync(STORE, "utf8")); } catch { /* first run */ }
const save = () => fs.writeFileSync(STORE, JSON.stringify(verified, null, 2));
const pending = new Map(); // nonce → { userId, at } — single use, 15 min
/* ---------- Mask.ID's public key: fetch once, pin --------------------------- */
let keyPromise = null;
function maskidKey() {
if (!keyPromise) {
keyPromise = fetch(`${MASKID}/.well-known/maskid-attest-key`)
.then((r) => r.json())
.then(({ public_key }) => crypto.createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(public_key, "base64url")]),
format: "der", type: "spki",
}))
.catch((e) => { keyPromise = null; throw e; });
}
return keyPromise;
}
// Verify the signature over the exact bytes, THEN parse. Returns the
// attestation or null.
async function verifyResponse(payloadB64, signatureB64) {
const bytes = Buffer.from(payloadB64, "base64url");
const ok = crypto.verify(null, bytes, await maskidKey(), Buffer.from(signatureB64, "base64url"));
return ok ? JSON.parse(bytes.toString("utf8")) : null;
}
/* ---------- Roles ---------------------------------------------------------- */
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
async function applyRoles(userId, rec) {
const guild = await client.guilds.fetch(GUILD_ID);
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) return;
const want = TIER_ROLES[rec.tier];
const drop = ALL_ROLES.filter((r) => r !== want && member.roles.cache.has(r));
if (drop.length) await member.roles.remove(drop, "Mask.ID tier changed");
if (want && !member.roles.cache.has(want)) await member.roles.add(want, `Mask.ID tier: ${rec.tier}`);
if (ROLE_HUMAN) {
const human = rec.human != null && rec.human >= HUMAN_MIN;
if (human && !member.roles.cache.has(ROLE_HUMAN)) await member.roles.add(ROLE_HUMAN, `Mask.ID human confidence ${rec.human}`);
if (!human && member.roles.cache.has(ROLE_HUMAN)) await member.roles.remove(ROLE_HUMAN, "Mask.ID human confidence below the bar");
}
}
async function stripRoles(userId) {
const guild = await client.guilds.fetch(GUILD_ID);
const member = await guild.members.fetch(userId).catch(() => null);
const roles = [...ALL_ROLES, ...(ROLE_HUMAN ? [ROLE_HUMAN] : [])];
if (member && roles.length) await member.roles.remove(roles, "Mask.ID verification ended");
}
// A fresh attestation for a member: record it and set the role.
async function accept(att, userId) {
const rec = { subject_id: att.subject_id, tier: att.trust_index_tier, human: att.human_confidence ?? null, at: new Date().toISOString() };
verified[userId] = rec;
save();
await applyRoles(userId, rec);
console.log(`verified ${userId}: tier ${rec.tier}${rec.human != null ? `, human ${rec.human}` : ""}`);
}
// Daily re-check: re-read every subject. 404 = revoked or gone → roles off.
async function resync() {
for (const [userId, rec] of Object.entries(verified)) {
try {
const res = await fetch(`${MASKID}/api/attest/v1/subject/${rec.subject_id}`);
if (res.status === 404) {
delete verified[userId]; save();
await stripRoles(userId);
console.log(`resync ${userId}: no longer verified`);
continue;
}
const { payload, signature } = await res.json();
const att = await verifyResponse(payload, signature);
const human = att?.human_confidence ?? null;
if (att && (att.trust_index_tier !== rec.tier || human !== rec.human)) {
rec.tier = att.trust_index_tier; rec.human = human; save();
await applyRoles(userId, rec);
console.log(`resync ${userId}: tier ${rec.tier}${human != null ? `, human ${human}` : ""}`);
}
} catch (e) { console.error(`resync ${userId} failed:`, e.message); }
}
}
/* ---------- /verify ---------------------------------------------------------- */
client.on("interactionCreate", async (i) => {
if (!i.isChatInputCommand() || i.commandName !== "verify") return;
const nonce = crypto.randomBytes(12).toString("hex");
pending.set(nonce, { userId: i.user.id, at: Date.now() });
const request = {
v: 1, rp_user_id: i.user.id, nonce,
webhook: `${PUBLIC_URL}/maskid/callback`, redirect: `${PUBLIC_URL}/return`,
scopes: SCOPES,
};
const link = `${MASKID}/verify-trust?c=${Buffer.from(JSON.stringify(request)).toString("base64url")}`;
const have = verified[i.user.id];
const embed = new EmbedBuilder()
.setTitle("Verify with Mask.ID")
.setURL(link)
.setDescription(
(have ? `You're verified here already (tier **${have.tier}**). Verify again to refresh.\n\n` : "") +
`[Open Mask.ID and approve](${link}) — you share your Trust Index tier` +
(ROLE_HUMAN ? " and, if you choose, your human-confidence score. " : ". ") +
`This server never learns who you are on Mask.ID. Link expires in 15 minutes.`)
.setColor(0x5b6bff);
await i.reply({ embeds: [embed], ephemeral: true });
});
/* ---------- Webhook + redirect listener --------------------------------------- */
const page = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
<body style="font:16px system-ui;max-width:32rem;margin:4rem auto;text-align:center"><h1>${title}</h1><p>${body}</p>`;
async function handle(payloadB64, signatureB64) {
const att = await verifyResponse(payloadB64, signatureB64);
if (!att) return "The signature didn't verify.";
const p = pending.get(att.nonce);
pending.delete(att.nonce);
if (!p || p.userId !== att.rp_user_id) return "That verification link was already used or has expired. Run /verify again.";
await accept(att, p.userId);
return null;
}
http.createServer(async (req, res) => {
const url = new URL(req.url, PUBLIC_URL);
try {
if (req.method === "POST" && url.pathname === "/maskid/callback") {
let body = ""; for await (const c of req) body += c;
const { payload, signature } = JSON.parse(body);
await handle(payload, signature);
res.writeHead(200, { "content-type": "application/json" }).end(`{"ok":true}`);
} else if (req.method === "GET" && url.pathname === "/return") {
const [payload, signature] = String(url.searchParams.get("maskid") ?? "").split(".");
const err = payload && signature ? await handle(payload, signature) : "Nothing to verify.";
res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
.end(err && !err.startsWith("That verification link was already")
? page("Not verified", err)
: page("Verified", "Your role is set. You can close this tab and go back to Discord."));
} else res.writeHead(404).end();
} catch (e) { console.error(e); res.writeHead(500).end(); }
}).listen(PORT, () => console.log(`Listening on ${PORT} — public at ${PUBLIC_URL}`));
client.once("ready", (c) => {
console.log(`Signed in as ${c.user.tag}; ${Object.keys(verified).length} verified members`);
resync();
setInterval(resync, SYNC_HOURS * 3600 * 1000);
setInterval(() => { for (const [n, p] of pending) if (Date.now() - p.at > 15 * 60 * 1000) pending.delete(n); }, 60 * 1000);
});
client.login(env.DISCORD_TOKEN);// Registers the /verify slash command with Discord. Run once (and again
// whenever you change the command definition): `npm run register`.
import { REST, Routes, SlashCommandBuilder } from "discord.js";
const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID } = process.env;
if (!DISCORD_TOKEN || !DISCORD_CLIENT_ID || !DISCORD_GUILD_ID) {
console.error("Set DISCORD_TOKEN, DISCORD_CLIENT_ID and DISCORD_GUILD_ID first.");
process.exit(1);
}
const commands = [
new SlashCommandBuilder()
.setName("verify")
.setDescription("Verify with Mask.ID and get a role by your Trust Index tier")
.toJSON(),
];
const rest = new REST().setToken(DISCORD_TOKEN);
await rest.put(Routes.applicationGuildCommands(DISCORD_CLIENT_ID, DISCORD_GUILD_ID), { body: commands });
console.log(`Registered ${commands.map((c) => "/" + c.name).join(", ")} in guild ${DISCORD_GUILD_ID}.`);{
"name": "maskid-discord-roles",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Members /verify with Mask.ID and receive a Discord role by Trust Index tier.",
"scripts": {
"register": "node register-commands.js",
"start": "node roles-bot.js"
},
"dependencies": {
"discord.js": "^14.16.3"
},
"engines": { "node": ">=18" }
}# Copy to .env and fill in. Never commit .env.
DISCORD_TOKEN=paste-the-bot-token
DISCORD_CLIENT_ID=paste-the-application-id
DISCORD_GUILD_ID=paste-your-server-id
# Where Mask.ID reaches this bot (https, publicly reachable). The bot
# listens on PORT; put Caddy/nginx/cloudflared in front of it.
PUBLIC_URL=https://bot.your-community.example
PORT=8787
# Role ids for each Mask.ID tier (Server Settings → Roles → … → Copy ID).
# Leave a tier blank to give it no role.
ROLE_TRUSTED=
ROLE_BUILDING=
ROLE_LOW=
ROLE_UNPROVEN=
# Optional "Verified Human" role: set a role id and the bot also asks
# members for Mask.ID's humanity pack, granting the role at HUMAN_MIN+
# human confidence (0–100). Members may decline the pack; no role then.
ROLE_HUMAN=
HUMAN_MIN=70
# Optional: re-check every member's tier this often (hours; default 24),
# and where to keep the subject ids (default ./verified.json).
SYNC_HOURS=24
STORE_FILE=./verified.json# /etc/systemd/system/maskid-roles.service — keeps the roles bot running.
[Unit]
Description=Mask.ID Discord roles bot
After=network-online.target
[Service]
User=maskidbot
WorkingDirectory=/opt/maskid-roles
EnvironmentFile=/opt/maskid-roles/.env
ExecStart=/usr/bin/node roles-bot.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetHow it behaves
Consent, every time. The member sees exactly what your server will receive before approving — the Trust Index and tier, nothing else — and can revoke from their Mask.ID account whenever they like. Revocation shows up here as the role quietly disappearing at the next daily check; the bot never announces demotions.
Bans that stick. The subject id is stable per member per server. Someone who is banned, revokes, and verifies again gets the same subject id back — so banning the Discord account is enough; there is no re-verify loophole.
License. Public domain (CC0), like the card bot.