@mrs-maid/verify
v1.0.0
Published
Client fuer das Mrs. Maid Verify Gateway: Sessions minten, Ergebnisse pollen, signierte Webhooks pruefen.
Downloads
15
Maintainers
Readme
@mrs-maid/verify
Client fuer das Mrs. Maid Verify Gateway. Session minten, Link an den User schicken, Ergebnis per Webhook oder Polling einsammeln.
Node ab 18, ESM und CJS, keine Runtime-Dependencies. node:crypto wird nur im
Webhook-Entry geladen, ein reiner Bot-Prozess zieht den Crypto-Teil nicht mit.
Installation
npm install @mrs-maid/verifySession minten
import { MrsMaidVerify } from '@mrs-maid/verify';
const verify = new MrsMaidVerify({ apiKey: process.env.MRSMAID_VERIFY_KEY! });
const session = await verify.createSession({
guildId: interaction.guildId,
userId: interaction.user.id,
captcha: true,
meta: { caseId: 'abc-123' },
});
await interaction.user.send(`Verifizierung: ${session.verifyUrl}`);session.captcha sagt, was tatsaechlich aktiv ist. Bei einem Plan ohne Captcha
steht da false, auch wenn der Request true geschickt hat. Zeig immer den
Serverwert an, nicht den eigenen Wunsch.
session.expiresAt sind Unix-Sekunden, per Default 15 Minuten. Danach verfaellt
die Session still. Das Gateway schreibt sie nicht auf einen eigenen Status um,
sie bleibt pending.
Ergebnis einsammeln
Zwei Wege. Nimm beide.
Webhook
Der Webhook ist der schnelle Weg, aber nicht garantiert: die Retry-Schleife lebt im Prozess des Gateways, ein Neustart verwirft laufende Wiederholungen.
import express from 'express';
import { expressWebhook } from '@mrs-maid/verify/webhook';
app.post(
'/mrsmaid/verify',
express.raw({ type: 'application/json', limit: '64kb' }),
expressWebhook({
secret: process.env.MRSMAID_VERIFY_SECRET!,
onEvent: async (result) => {
if (result.verdict === 'kick') await guild.members.kick(result.userId);
else if (result.isVerified) await member.roles.add(verifiedRole);
},
}),
);Der Handler antwortet 204 und arbeitet onEvent erst danach ab. Der Sender
bricht nach 8 Sekunden ab und stellt sonst unnoetig erneut zu.
Zwei Dinge, an denen Integrationen regelmaessig scheitern:
express.json()zerstoert die Signaturpruefung. Signiert wird der rohe Body. Auf dieser Route mussexpress.rawstehen, sonst kommt bei jeder Zustellung 400 zurueck.- Der Empfaenger muss idempotent sein. Bei einem Timeout kommt derselbe
sessionIdmehrfach an. Nimm ihn als Idempotenz-Key.
Ohne Express geht es genauso, nur von Hand:
import { verifyWebhook, WebhookVerificationError } from '@mrs-maid/verify/webhook';
try {
const result = verifyWebhook({
secret: process.env.MRSMAID_VERIFY_SECRET!,
rawBody, // string oder Buffer, ungeparst
signature: headers['x-mrsmaid-signature'],
timestamp: headers['x-mrsmaid-timestamp'],
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
// 'bad_signature' | 'stale_timestamp' | 'malformed'
console.warn(error.reason);
}
}Geprueft wird in dieser Reihenfolge: Timestamp gegen die eigene Uhr, Toleranz
300 Sekunden, dann die Signatur mit timingSafeEqual, dann erst JSON.parse.
Wer zuerst parst, laesst fremdes JSON in den eigenen Code.
Die Webhook-URL muss oeffentliches HTTPS sein. localhost, .local,
.internal, private IPv4-Bereiche und IPv6-Literale lehnt das Gateway ab.
Lokal entwickeln heisst also Tunnel oder Polling.
Polling
waitForResult pollt bis completed oder bis das Zeitbudget leer ist. Ohne
timeoutMs nimmt der Client den Rest bis expiresAt der geminteten Session.
import { VerifyApiError } from '@mrs-maid/verify';
try {
const result = await verify.waitForResult(session.sessionId);
} catch (error) {
if (error instanceof VerifyApiError && error.code === 'timeout') {
// User hat den Link nicht angefasst
}
}Polling verbraucht keine Quota. Weil der Webhook einen Gateway-Neustart nicht
ueberlebt, gehoert ein Reconcile-Lauf ueber getSession in jeden Bot, der
Ergebnisse nicht verlieren darf: offene Sessions aus der eigenen Datenbank
holen, nachfragen, fertig.
const state = await verify.getSession(sessionId);
if (state.status === 'completed') await apply(state);Was im Ergebnis steht
Der Plan des Tenants entscheidet, welche Felder geliefert werden. Alle Tier-Felder sind deshalb optional typisiert.
| Tier | Plan | Felder |
|---|---|---|
| 0 | alle | isVerified, isInDiscord |
| 1 | ab indie | isAlt, altUserIds, trustScore, signals, deviceSimilarity, deviceClusterSize, correlation, verdict, thresholds |
| 2 | ab pro | penalties, telemetry, captchaPassed, altMatches |
event, sessionId, guildId, userId, meta und completedAt kommen
immer, das sind deine eigenen Daten.
Ein fehlendes Feld ist kein Fehler, sondern der Plan. trustScore ?? 100 ist
ein Bug: undefined heisst "nicht gekauft", nicht "sauber".
Messung gegen Policy
deviceSimilarity, deviceClusterSize, altMatches, correlation und
trustScore sind Messwerte und haengen an keiner Kundeneinstellung. verdict
ist daraus abgeleitet, mit den Schwellen aus thresholds. Beide kommen mit,
damit du verdict nachrechnen oder durch eine eigene Regel ersetzen kannst.
const verdict =
result.deviceSimilarity !== undefined && result.deviceSimilarity >= 85 ? 'kick' : 'verify';Drei Fallen
isVerified: trueheisst nur "kein angefordertes Captcha durchgefallen". Es ist kein Freigabesignal. Die Entscheidung faellt ueberverdictoder eigene Schwellen.altUserIdsenthaelt fremde Discord-IDs. Wer die in einen oeffentlichen Channel schreibt, veroeffentlicht die Verknuepfung zweier Accounts. Mod-Log statt Public-Embed.correlation.burstNetworkCountist bewusst keine Alt-Aussage. Hinter Uni-NAT oder Mobilfunk-CGNAT sind das systematisch Fremde.
Limits
POST /session verbraucht eine Einheit der Tagesquota, Polling nicht. Nach
jedem Mint steht der Stand am Client:
await verify.createSession({ guildId, userId });
verify.rateLimit; // { limit: 500, remaining: 487, resetInSeconds: 48213 }Fehler
Alles, was vom Gateway kommt, wirft VerifyApiError mit code, status und
retryable. Bei quota_exceeded haengen zusaetzlich limit,
resetInSeconds und retryAfterSeconds dran.
try {
await verify.createSession({ guildId, userId });
} catch (error) {
if (error instanceof VerifyApiError && error.code === 'quota_exceeded') {
log.warn(`Quota leer, zurueck in ${error.resetInSeconds} s`);
}
}Codes: bad_request, meta_too_large, bad_json, missing_api_key,
invalid_api_key, tenant_suspended, not_found, payload_too_large,
rate_limited, quota_exceeded, internal_error, unavailable,
network_error, bad_response, timeout.
guildId, userId und die Groesse von meta prueft der Client lokal. Diese
Fehler kosten keinen Netzaufruf und keine Quota-Einheit.
Wiederholt wird automatisch bei rate_limited, internal_error und
unavailable, per Default zweimal, mit Retry-After wenn der Server einen
schickt. Nach Timeout oder Verbindungsabbruch wiederholt der Client nur GET.
Ein POST /session koennte den Server trotzdem erreicht haben, und ein blinder
zweiter Versuch bucht eine zweite Session.
API
new MrsMaidVerify({
apiKey: string, // mm_vrf_..., nicht das Webhook-Secret
baseUrl?: string, // Default 'https://mrs-maid.cc'
fetch?: typeof fetch,
timeoutMs?: number, // Default 10000, pro HTTP-Aufruf
maxRetries?: number, // Default 2
});
verify.info(): Promise<ApiInfo>
verify.createSession(input): Promise<CreatedSession>
verify.getSession(sessionId): Promise<SessionState>
verify.waitForResult(sessionId, { intervalMs?, timeoutMs?, signal? }): Promise<VerifyResult>
verify.rateLimit: RateLimitInfo | nullAus @mrs-maid/verify/webhook:
verifyWebhook({ secret, rawBody, signature, timestamp, toleranceSeconds? }): VerifyResult
expressWebhook({ secret, onEvent, onError?, toleranceSeconds? }): RequestHandler
expectedSignature(secret, rawBody, timestamp): string
deriveWebhookKey(secret): string
WebhookVerificationErrorDer API-Key und das Signing-Secret sind zwei verschiedene Dinge. Der Key mintet Sessions, das Secret prueft Webhooks. Zwei getrennte Env-Variablen.
Entwicklung
npm install
npm test # vitest
npm run typecheck # tsc --noEmit
npm run build # tsup, ESM + CJS + Typen
npm run check # alle drei, laeuft auch vor npm publishEinzelner Test:
npx vitest run test/webhook.test.ts
npx vitest run -t "rejects a single changed byte"Lizenz
MIT
