Your Mask.ID tier,
on every Nostr client at once.
Mask.ID is a Nostr publisher. For members who opt in, Mask.ID’s own key publishes their Trust Index tier — never the number, never an address — as a signed, standard event any relay carries and any client, bot or community can read. Damus, Amethyst, Vector, Armada, Ditto: no plugin, no API key, no partnership. Building and Trusted members also get a badge they can accept on their profile.
Turn it on, accept the badge
One checkbox in Settings. What it publishes, what it never publishes, and how to undo it.
For developers & communitiesRead it from any relay
The publisher key, the event schema, an example, how to verify — and the gate bot for NIP-29 communities.
For members
What it does
Once you have verified a Nostr or Vector account on Mask.ID, Settings shows a Nostr section with one checkbox: Publish my tier to Nostr. Tick it and Mask.ID publishes, against each npub you have verified, a small signed statement: this npub is Mask.ID member @you, currently in the Trusted band (or Building, Low, Unproven — the same four bands as your avatar ring). When your tier changes, the statement is replaced. Nostr clients that understand these events show it on your profile; communities can admit or promote you by it.
If you are Building or Trusted, Mask.ID also awards you a badge. Badges on Nostr are opt-in twice over: Mask.ID awards it, and you choose whether to display it, in your own client (Damus: Profile → Edit → Badges; Amethyst: Profile → Badges; others vary). Until you accept it, nobody sees it.
What it never publishes
- Your Trust Index number — only the band.
- Any wallet address, your referrers, your Trust Scores, or which platforms you verified.
- Anything at all, unless you ticked the box. It is off by default for everyone.
Before you tick it
This publicly links your npub to your Mask.ID username on relays Mask.ID does not control. Turning it off sends every relay a standard deletion request, and the major relays honor it — but Nostr has no central delete. Treat it as hard to fully undo, and use a separate npub if you keep your Nostr identity apart from your Mask.ID one.
For developers & communities
The publisher key
Every event is signed by Mask.ID’s Nostr key. Pin it; verify signatures against it; ignore anything claiming to be Mask.ID that isn’t signed by it.
npub npub1uhq0ye3a9pjgml864ml38lenthgeuq2z0l3rh2pspl6wqrv8qrjq2mcrhf
hex e5c0f2663d28648dfcfaaeff13ff335dd19e01427fe23ba8300ff4e00d8700e4
NIP-05 _@mask.id → https://mask.id/.well-known/nostr.jsonThe same key is listed in maskid.json next to the Ed25519 attestation key, so a machine reader finds both in one place.
The tier assertion — kind 30382 (NIP-85)
One addressable event per member pubkey, authored by the publisher key, with d = the member’s pubkey. A newer event replaces the older one, so a relay holds exactly the current tier. Query relays for {"kinds":[30382], "authors":["e5c0f2663d28648dfcfaaeff13ff335dd19e01427fe23ba8300ff4e00d8700e4"], "#d":["<member pubkey>"]}.
{
"kind": 30382,
"pubkey": "e5c0f2663d28648dfcfaaeff13ff335dd19e01427fe23ba8300ff4e00d8700e4",
"created_at": 1788103120,
"tags": [
["d", "c05a35b4ef084084c008c519ecca229d63211a101f3cf5917c131ed7ecf5e044"],
["p", "c05a35b4ef084084c008c519ecca229d63211a101f3cf5917c131ed7ecf5e044"],
["tier", "unproven"],
["username", "mask_97lhqu"],
["L", "mask.id"],
["l", "unproven", "mask.id"],
["issued", "2026-08-30T15:18:40Z"]
],
"content": "Mask.ID · @mask_97lhqu · Unproven",
"id": "…", "sig": "…"
}| Tag | Meaning |
|---|---|
d, p | The member’s pubkey (hex). Both, so the event is addressable and shows up in “mentions me” queries. |
tier | trusted · building · low · unproven — Mask.ID’s bands, identical to trust_index_tier in the Attestation API. Gate on the word, not on numbers you hard-code. |
username | The member’s current Mask.ID username. Profile: https://app.mask.id/u/<username>. |
L / l | NIP-32 label namespace mask.id with the tier as the label, for label-aware clients and filters. |
issued | When this version was published (ISO 8601). Prefer created_at for ordering. |
There is deliberately no rank tag. NIP-85 allows a 0–100 rank; Mask.ID publishes the band only. If you need the number, ask the member for it through the Attestation API — they grant it per site.
Badges — NIP-58
Two badge definitions (kind 30009) live under the publisher key: tier-building and tier-trusted. A member in one of those bands receives a kind 8 award referencing 30009:e5c0f2663d28648dfcfaaeff13ff335dd19e01427fe23ba8300ff4e00d8700e4:tier-trusted (or -building). Low and Unproven earn no badge. When a tier drops, the old award gets a deletion request; when it rises, a new award follows. Clients render the badge only after the member accepts it.
Withdrawal — NIP-09
When a member opts out, unlinks the npub, or deletes their profile, Mask.ID publishes a kind 5 deletion request carrying the a coordinate of the assertion and the e ids of any awards. Honor it: treat a deleted assertion as “no statement”, never as “low”.
Where to read
Mask.ID publishes to wss://asia.vectorapp.io/nostr, wss://nos.lol, wss://relay.damus.io, wss://relay.nostr.band and the profile indexer wss://purplepag.es (profiles only). The publisher’s kind 10002 relay list is authoritative if that set changes. Any relay that receives the events from elsewhere is equally valid — the signature is the truth, not the relay.
Verify it yourself
# the publisher’s profile, relay list and badge definitions
nak req -k 0 -k 10002 -k 30009 -a npub1uhq0ye3a9pjgml864ml38lenthgeuq2z0l3rh2pspl6wqrv8qrjq2mcrhf wss://nos.lol
# every current tier assertion
nak req -k 30382 -a npub1uhq0ye3a9pjgml864ml38lenthgeuq2z0l3rh2pspl6wqrv8qrjq2mcrhf wss://nos.lol
# one member (hex pubkey), and check the signature
nak req -k 30382 -a npub1uhq0ye3a9pjgml864ml38lenthgeuq2z0l3rh2pspl6wqrv8qrjq2mcrhf -d <member-hex> wss://nos.lol | nak verifyThe gate bot · NIP-29 communities
What it does
A small open bot for any NIP-29 relay-based group — Armada servers, 0xchat, chachi, Flotilla, or a relay you run. Give it an admin role in your group and it admits people by their Mask.ID tier: someone asks to join (kind 9021), the bot reads Mask.ID’s signed assertion about their pubkey from the public relays, and if the tier meets your MIN_TIER it adds them (kind 9000). No Mask.ID account, API key or consent flow on your side — the member opted in on Mask.ID, and the assertion is public.
It keeps watching. A tier that later drops below your minimum, or an assertion Mask.ID withdraws (the member opted out), removes the member (kind 9001). Once a day it re-checks everyone in the group. Admins are never touched, and people without any Mask.ID assertion are ignored on join and — by default — left alone if they were added by hand.
What it stores. Nothing. The group relay is the state; the bot is stateless and can be restarted freely.
Setup
- Make a key for the bot
nak key generate(or any Nostr client). Keep the nsec in.env; note the npub. - Give it an admin role in your groupWhatever role your relay lets put and remove users — on most relays that is the group admin/moderator role. As a group admin, publish
kind 9000with["p", "<bot pubkey>", "<role>"], or use your client’s member settings. Set the group to closed so joins need approval; the bot is the approver. - Get the codeDownload the files below into a folder (say
maskid-gate-bot/) and runnpm install. Node 22 or newer (it uses the built-in WebSocket). - ConfigureCopy
env.exampleto.env: the bot’s nsec, your relay, the group id, andMIN_TIER. Optional roles for admitted or Trusted members — don’t hand out an admin role that way, or the bot can never remove them again. - Start
npm start. Ask to join from another key and watch the log. To keep it running, use the systemd unit below.
The code
// Mask.ID NIP-29 gate bot — admits (and removes) members of a NIP-29 group
// by their Mask.ID Trust Index tier, read from the signed kind-30382
// assertions Mask.ID publishes. No API key, no consent flow: members opt in
// on Mask.ID (Settings → Nostr), and their tier is public on relays.
//
// What it does, in one loop:
// 1. Watches the group relay for join requests (kind 9021) to your group.
// 2. For each requester, reads Mask.ID's current assertion about that
// pubkey (kind 30382 signed by Mask.ID's key) from the Mask.ID relays.
// 3. If the tier meets MIN_TIER, admits them (kind 9000 put-user), with an
// optional extra role for Trusted members. Otherwise stays silent.
// 4. Keeps watching Mask.ID's relays: a tier that drops below MIN_TIER, or
// an assertion withdrawn (kind 5), removes the member (kind 9001).
// 5. Re-checks every current member once a day, in case events were missed.
//
// The bot needs an admin role in the group (the relay decides which roles may
// put/remove users). Nothing is stored on disk; the group relay is the state.
//
// Run: node --env-file=.env bot.js (Node 22+; see env.example)
import { SimplePool, finalizeEvent, verifyEvent, nip19 } from "nostr-tools";
const env = process.env;
const need = (k) => { if (!env[k]) { console.error(`missing ${k}`); process.exit(1); } return env[k]; };
const BOT_SK = decodeSecret(need("BOT_NSEC"));
const GROUP_RELAY = need("GROUP_RELAY"); // wss://groups.example.com
const GROUP_ID = need("GROUP_ID"); // the group's id (the h tag)
const MASKID_PUBKEY = env.MASKID_PUBKEY ?? // Mask.ID's publisher key (hex)
"e5c0f2663d28648dfcfaaeff13ff335dd19e01427fe23ba8300ff4e00d8700e4";
const MASKID_RELAYS = (env.MASKID_RELAYS ?? "wss://nos.lol,wss://asia.vectorapp.io/nostr,wss://relay.damus.io")
.split(",").map((s) => s.trim()).filter(Boolean);
const TIERS = ["unproven", "low", "building", "trusted"]; // Mask.ID's bands, lowest first
const MIN_TIER = (env.MIN_TIER ?? "building").toLowerCase(); // admit at this band or above
const ROLE_TRUSTED = env.ROLE_TRUSTED || null; // optional role name for Trusted (NOT an admin role — admins are never removed)
const ROLE_MEMBER = env.ROLE_MEMBER || null; // optional role for everyone admitted
const RECHECK_HOURS = Number(env.RECHECK_HOURS ?? 24);
// Remove members who have NO Mask.ID assertion at all at recheck time? Off by
// default: members added by hand (or before the bot) stay unless Mask.ID
// explicitly withdraws or lowers their tier. Admins are never touched.
const REMOVE_UNASSERTED = env.REMOVE_UNASSERTED === "true";
if (!TIERS.includes(MIN_TIER)) { console.error(`MIN_TIER must be one of ${TIERS.join(", ")}`); process.exit(1); }
const rank = (tier) => TIERS.indexOf(tier);
const admits = (tier) => tier != null && rank(tier) >= rank(MIN_TIER);
const pool = new SimplePool();
const botPubkey = publicKeyOf(BOT_SK);
console.log(`gate bot ${nip19.npubEncode(botPubkey)} · group ${GROUP_ID} on ${GROUP_RELAY} · MIN_TIER=${MIN_TIER}`);
/* ---------- Mask.ID assertions ------------------------------------------- */
// The newest kind-30382 Mask.ID has published about `pubkey`, or null.
async function assertionFor(pubkey) {
const events = await pool.querySync(MASKID_RELAYS, {
kinds: [30382], authors: [MASKID_PUBKEY], "#d": [pubkey],
});
const ev = events.filter(verifyEvent).sort((a, b) => b.created_at - a.created_at)[0];
return ev ? parseAssertion(ev) : null;
}
function parseAssertion(ev) {
const tag = (name) => ev.tags.find((t) => t[0] === name)?.[1];
return { pubkey: tag("d"), tier: tag("tier"), username: tag("username"), at: ev.created_at };
}
/* ---------- group actions -------------------------------------------------- */
async function publish(relay, template) {
const ev = finalizeEvent({ ...template, created_at: Math.floor(Date.now() / 1000) }, BOT_SK);
try {
await Promise.any(pool.publish([relay], ev));
return true;
} catch (e) {
console.error(`relay refused kind ${ev.kind}:`, e?.message ?? e);
return false;
}
}
async function admit(pubkey, tier, username) {
const roles = [];
if (ROLE_MEMBER) roles.push(ROLE_MEMBER);
if (ROLE_TRUSTED && tier === "trusted") roles.push(ROLE_TRUSTED);
const ok = await publish(GROUP_RELAY, {
kind: 9000,
tags: [["h", GROUP_ID], ["p", pubkey, ...roles]],
content: `Mask.ID tier ${tier}${username ? ` (@${username})` : ""}`,
});
if (ok) console.log(`admitted ${short(pubkey)} — ${tier}${roles.length ? ` roles ${roles.join(",")}` : ""}`);
}
async function remove(pubkey, why) {
const ok = await publish(GROUP_RELAY, {
kind: 9001, tags: [["h", GROUP_ID], ["p", pubkey]], content: why,
});
if (ok) console.log(`removed ${short(pubkey)} — ${why}`);
}
async function currentMembers() {
const [members] = await pool.querySync([GROUP_RELAY], { kinds: [39002], "#d": [GROUP_ID] });
return new Set((members?.tags ?? []).filter((t) => t[0] === "p").map((t) => t[1]));
}
// Admins (kind 39001) are never gated — the bot must not remove the people
// who gave it its role.
async function currentAdmins() {
const [admins] = await pool.querySync([GROUP_RELAY], { kinds: [39001], "#d": [GROUP_ID] });
return new Set((admins?.tags ?? []).filter((t) => t[0] === "p").map((t) => t[1]));
}
/* ---------- decisions ------------------------------------------------------ */
async function considerJoin(pubkey) {
if (pubkey === botPubkey) return;
const a = await assertionFor(pubkey);
if (!a) return console.log(`join ${short(pubkey)}: no Mask.ID assertion — ignored`);
if (admits(a.tier)) await admit(pubkey, a.tier, a.username);
else console.log(`join ${short(pubkey)}: tier ${a.tier} below ${MIN_TIER} — ignored`);
}
async function reconsiderMember(pubkey, tierNow, { unasserted = false } = {}) {
if (pubkey === botPubkey) return;
const [members, admins] = await Promise.all([currentMembers(), currentAdmins()]);
if (!members.has(pubkey) || admins.has(pubkey)) return;
if (tierNow == null) {
if (unasserted && !REMOVE_UNASSERTED) return;
return remove(pubkey, unasserted ? "no Mask.ID assertion" : "Mask.ID assertion withdrawn");
}
if (!admits(tierNow)) return remove(pubkey, `Mask.ID tier now ${tierNow}`);
}
async function recheckAll() {
const members = await currentMembers();
console.log(`recheck: ${members.size} members`);
for (const pubkey of members) {
if (pubkey === botPubkey) continue;
const a = await assertionFor(pubkey);
await reconsiderMember(pubkey, a?.tier ?? null, { unasserted: !a });
}
}
/* ---------- subscriptions -------------------------------------------------- */
const since = () => Math.floor(Date.now() / 1000) - 60;
// join requests to our group
pool.subscribeMany([GROUP_RELAY], { kinds: [9021], "#h": [GROUP_ID], since: since() }, {
onevent: (ev) => considerJoin(ev.pubkey).catch(console.error),
});
// Mask.ID's assertions and withdrawals, live
pool.subscribeMany(MASKID_RELAYS, { kinds: [30382, 5], authors: [MASKID_PUBKEY], since: since() }, {
onevent: (ev) => {
if (!verifyEvent(ev)) return;
if (ev.kind === 30382) {
const a = parseAssertion(ev);
reconsiderMember(a.pubkey, a.tier).catch(console.error);
} else {
// NIP-09: an `a` tag 30382:<maskid>:<member pubkey> names the member
for (const t of ev.tags) {
if (t[0] === "a" && t[1].startsWith(`30382:${MASKID_PUBKEY}:`))
reconsiderMember(t[1].split(":")[2], null).catch(console.error);
}
}
},
});
recheckAll().catch(console.error);
setInterval(() => recheckAll().catch(console.error), RECHECK_HOURS * 3600 * 1000);
/* ---------- helpers -------------------------------------------------------- */
function decodeSecret(v) {
if (v.startsWith("nsec1")) {
const { type, data } = nip19.decode(v);
if (type !== "nsec") throw new Error("BOT_NSEC is not an nsec");
return data;
}
return Uint8Array.from(Buffer.from(v, "hex"));
}
function publicKeyOf(sk) { return finalizeEvent({ kind: 1, tags: [], content: "", created_at: 0 }, sk).pubkey; }
function short(pk) { return pk.slice(0, 8) + "…"; }
{
"name": "maskid-nostr-gate-bot",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": { "start": "node --env-file=.env bot.js" },
"dependencies": { "nostr-tools": "^2.25.0" }
}
# Copy to .env and fill in. Never commit .env.
BOT_NSEC=nsec1... # the bot's own key; give it an admin role in the group
GROUP_RELAY=wss://groups.example.com # your NIP-29 relay
GROUP_ID=abc123 # the group id (the h tag)
MIN_TIER=building # admit at this Mask.ID band or above: unproven | low | building | trusted
# ROLE_MEMBER= # optional role name to give everyone admitted (as defined by your relay)
# ROLE_TRUSTED= # optional extra role for Trusted members. NOTE: if this role makes them an
# # admin on your relay (kind 39001), the bot will never remove them again.
# MASKID_RELAYS=wss://nos.lol,wss://asia.vectorapp.io/nostr,wss://relay.damus.io
# RECHECK_HOURS=24
# REMOVE_UNASSERTED=false # true = at recheck, also remove members with NO Mask.ID assertion (admins are never removed)
# /etc/systemd/system/maskid-gate-bot.service — keeps the bot running.
[Unit]
Description=Mask.ID NIP-29 gate bot
After=network-online.target
[Service]
WorkingDirectory=/opt/maskid-gate-bot
EnvironmentFile=/opt/maskid-gate-bot/.env
ExecStart=/usr/bin/node bot.js
Restart=always
RestartSec=5
User=nobody
[Install]
WantedBy=multi-user.target
Trust model: the bot trusts exactly one key — Mask.ID’s publisher key above — and checks every signature. A relay that served a forged assertion would fail that check. What it cannot know is whether the person typing in your group is the person who holds the key; on Nostr, the key is the person.
How it behaves
Opt-in only, per member. No event exists for a member who has not ticked the box; absence means nothing. Tier only. Verification of the npub happens on Mask.ID first (a signed status event from that key), so an assertion about a pubkey means the member proved control of it. Replaceable. Keep the newest by created_at; discard the rest. One key. If the publisher key ever changes it will be announced here, on /api and in nostr.json; there is no rotation planned.