|
| 1 | +--- |
| 2 | +title: Creating a verifier |
| 3 | +sidebar_label: Creating a verifier |
| 4 | +sidebar_position: 4 |
| 5 | +--- |
| 6 | + |
| 7 | +# Creating a verifier |
| 8 | + |
| 9 | +A verifier is a class that extends one of the two base classes in |
| 10 | +`services/verifier-bot/src/verifier.ts` and is exported as part of a `Platform`. |
| 11 | +Add a file under `services/verifier-bot/src/platforms/` and register it in |
| 12 | +`platforms.ts`. |
| 13 | + |
| 14 | +Claim data is carried as `ClaimField`s (`{ key: number; value: string }`); most |
| 15 | +platforms use a single field with `key: 0` holding the username/handle. |
| 16 | + |
| 17 | +## Text verifier |
| 18 | + |
| 19 | +Extend `TextVerifier` when ownership can be proven by the user placing their |
| 20 | +token in a public bio/description. Implement `getText` (fetch the bio for a |
| 21 | +claim field) and `getClaimFieldsByUrl` (derive claim fields from a profile URL). |
| 22 | +Optional `testData*` arrays drive the `health-check` endpoint. |
| 23 | + |
| 24 | +```typescript |
| 25 | +import type { ClaimField, Platform } from '../models.js'; |
| 26 | +import { Result } from '../result.js'; |
| 27 | +import { createCookieEnabledAxios } from '../utility.js'; |
| 28 | +import { |
| 29 | + TextVerifier, |
| 30 | + type TextVerifierGetClaimFieldsTestData, |
| 31 | +} from '../verifier.js'; |
| 32 | + |
| 33 | +class GithubTextVerifier extends TextVerifier { |
| 34 | + protected testDataGetClaimFields: TextVerifierGetClaimFieldsTestData[] = [ |
| 35 | + { url: 'https://github.com/futo-org', expectedClaimFields: [{ key: 0, value: 'futo-org' }] }, |
| 36 | + ]; |
| 37 | + |
| 38 | + constructor() { |
| 39 | + super('GitHub'); |
| 40 | + } |
| 41 | + |
| 42 | + protected async getText(claimField: ClaimField): Promise<Result<string>> { |
| 43 | + const client = createCookieEnabledAxios(); |
| 44 | + const profile = await client({ url: `https://api.github.com/users/${claimField.value}` }); |
| 45 | + if (profile.status !== 200) { |
| 46 | + return Result.err({ message: 'Unable to find your account' }); |
| 47 | + } |
| 48 | + return Result.ok(profile.data.bio.trim()); |
| 49 | + } |
| 50 | + |
| 51 | + public async getClaimFieldsByUrl(url: string): Promise<Result<ClaimField[]>> { |
| 52 | + const match = /https:\/\/(?:www\.)?github\.com\/([^/]+)\/?/.exec(url); |
| 53 | + if (!match) return Result.err({ message: 'Failed to match regex' }); |
| 54 | + return Result.ok([{ key: 0, value: match[1] }]); |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +export const Github: Platform = { |
| 59 | + name: 'GitHub', |
| 60 | + verifiers: [new GithubTextVerifier()], |
| 61 | + version: 1, |
| 62 | +}; |
| 63 | +``` |
| 64 | + |
| 65 | +For platforms with anti-bot protection where a plain HTTP request won't return |
| 66 | +the bio, fetch the page through the shared Puppeteer browser instead (see |
| 67 | +`kick.ts`). Prefer a plain request whenever it works — the headless browser is |
| 68 | +slower and less reliable. |
| 69 | + |
| 70 | +## OAuth verifier |
| 71 | + |
| 72 | +Extend `OAuthVerifier<T>` when the user proves ownership by signing in. |
| 73 | +Implement `getOAuthURL` (where to send the user), `getToken` (exchange the OAuth |
| 74 | +code, returning the authenticated username), and `isTokenValid` (confirm the |
| 75 | +token's account matches the claim). Read credentials from |
| 76 | +`process.env.POLYCENTRIC_VERIFIER_BOT_*`. |
| 77 | + |
| 78 | +```typescript |
| 79 | +import type { ClaimField, Platform, TokenResponse } from '../models.js'; |
| 80 | +import { Result } from '../result.js'; |
| 81 | +import { getCallbackForPlatform } from '../utility.js'; |
| 82 | +import { OAuthVerifier } from '../verifier.js'; |
| 83 | + |
| 84 | +type DiscordTokenRequest = { code: string }; |
| 85 | + |
| 86 | +class DiscordOAuthVerifier extends OAuthVerifier<DiscordTokenRequest> { |
| 87 | + constructor() { |
| 88 | + super('Discord'); |
| 89 | + } |
| 90 | + |
| 91 | + public async getOAuthURL(): Promise<Result<string>> { |
| 92 | + const clientId = process.env.POLYCENTRIC_VERIFIER_BOT_DISCORD_CLIENT_ID; |
| 93 | + if (!clientId) return Result.errMsg('Verifier not configured'); |
| 94 | + const redirectUri = getCallbackForPlatform(this.platform, true); |
| 95 | + return Result.ok( |
| 96 | + `https://discord.com/api/oauth2/authorize?client_id=${clientId}` + |
| 97 | + `&redirect_uri=${redirectUri}&response_type=code&scope=identify`, |
| 98 | + ); |
| 99 | + } |
| 100 | + |
| 101 | + public async getToken(data: DiscordTokenRequest): Promise<Result<TokenResponse>> { |
| 102 | + // Exchange data.code for an access token, look up the account, and return |
| 103 | + // { username, token }. |
| 104 | + } |
| 105 | + |
| 106 | + public async isTokenValid(challenge: string, claimFields: ClaimField[]): Promise<Result<void>> { |
| 107 | + // Decode the token from `challenge`, fetch the account, and compare its |
| 108 | + // username to claimFields[0].value. |
| 109 | + } |
| 110 | + |
| 111 | + public healthCheck(): Promise<Result<void>> { |
| 112 | + throw new Error('Method not implemented.'); |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +export const Discord: Platform = { |
| 117 | + name: 'Discord', |
| 118 | + verifiers: [new DiscordOAuthVerifier()], |
| 119 | + version: 1, |
| 120 | +}; |
| 121 | +``` |
| 122 | + |
| 123 | +See `discord.ts` for a complete `getToken` / `isTokenValid` implementation. |
| 124 | + |
| 125 | +## Register the platform |
| 126 | + |
| 127 | +Add the exported `Platform` to the `platforms` array in |
| 128 | +`services/verifier-bot/src/platforms/platforms.ts`. Routes, `/platforms` |
| 129 | +listings, and health checks are generated automatically from that array and |
| 130 | +each verifier's `platform` name and `verifierType`. |
0 commit comments