Skip to content

Commit 0cf4604

Browse files
committed
Verifier bot service [#167]
Changelog: feature
1 parent 6dd6dfa commit 0cf4604

51 files changed

Lines changed: 5918 additions & 103 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitlab/ci/build_js_sdks.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ js-sdks-build:
5656
- !reference [.rs-core-workflow, rules]
5757
- !reference [.rn-sdk-workflow, rules]
5858
- !reference [.app-workflow, rules]
59+
- !reference [.verifier-bot-workflow, rules]
5960
- !reference [.release-workflow, rules]
6061

6162
.js-sdks-publish-npm:

.gitlab/ci/build_rs_core.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ rs-core-uniffi-web-build:
9494
- !reference [.js-sdk-workflow, rules]
9595
- !reference [.rn-sdk-workflow, rules]
9696
- !reference [.app-workflow, rules]
97+
- !reference [.verifier-bot-workflow, rules]
9798
- !reference [.release-workflow, rules]
9899
# Self-populating per job: the first run downloads and compiles
99100
# everything and uploads the result; every run after starts with warm

.gitlab/ci/build_services.yml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,102 @@ scraper-image:
268268
rules:
269269
- !reference [.scraper-workflow, rules]
270270
- !reference [.release-workflow, rules]
271+
272+
########################################################################
273+
# verifier-bot (Node/TypeScript)
274+
########################################################################
275+
276+
.verifier-bot-workflow:
277+
image: gitlab.futo.org:5050/devops/manifest-repo/library/docker:latest
278+
rules:
279+
- if: $CI_COMMIT_TAG =~ /^verifier-bot-/
280+
when: always
281+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
282+
changes:
283+
- pnpm-lock.yaml
284+
- pnpm-workspace.yaml
285+
- package.json
286+
- tools/**/*
287+
- patches/**/*
288+
- packages/js-*/**/*
289+
- packages/rs-core-uniffi-web/**/*
290+
- services/verifier-bot/**/*
291+
- .gitlab/ci/build_services.yml
292+
- .gitlab/ci/deploy_services.yml
293+
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
294+
when: never
295+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
296+
changes:
297+
- pnpm-lock.yaml
298+
- pnpm-workspace.yaml
299+
- package.json
300+
- tools/**/*
301+
- patches/**/*
302+
- packages/js-*/**/*
303+
- packages/rs-core-uniffi-web/**/*
304+
- services/verifier-bot/**/*
305+
- .gitlab/ci/build_services.yml
306+
- .gitlab/ci/deploy_services.yml
307+
variables:
308+
SERVICE_NAME: verifier-bot
309+
SERVICE_DIR: verifier-bot
310+
311+
# Biome lint for the bot. Lints source only, so it needs no build artifacts.
312+
verifier-bot-biome-check:
313+
extends: .verifier-bot-workflow
314+
image: node:24-slim
315+
stage: check
316+
allow_failure: true
317+
before_script:
318+
- npm install -g pnpm@10
319+
- pnpm config set @polycentric:registry https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/
320+
- pnpm install --ignore-scripts
321+
script:
322+
- pnpm exec biome check services/verifier-bot --diagnostic-level=error
323+
rules:
324+
- !reference [.verifier-bot-workflow, rules]
325+
326+
# Credentials come from the VERIFIER_BOT_ENV_VARS CI/CD file variable (base64-encoded .env), decoded into
327+
# the .test.env the suite loads.
328+
verifier-bot-tests:
329+
extends: .verifier-bot-workflow
330+
image: node:24-slim
331+
stage: test
332+
allow_failure: true
333+
needs:
334+
- job: js-sdks-build
335+
artifacts: true
336+
- job: rs-core-uniffi-web-build
337+
artifacts: true
338+
variables:
339+
PUPPETEER_SKIP_DOWNLOAD: '1'
340+
POLYCENTRIC_VERIFIER_BOT_PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
341+
before_script:
342+
- apt-get update && apt-get install -y --no-install-recommends chromium python3 make g++ && rm -rf /var/lib/apt/lists/*
343+
- npm install -g pnpm@10
344+
- pnpm config set @polycentric:registry https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/
345+
- pnpm install
346+
- if [ -n "$VERIFIER_BOT_ENV_VARS" ]; then base64 -d "$VERIFIER_BOT_ENV_VARS" > services/verifier-bot/.test.env; fi
347+
script:
348+
- pnpm --filter @polycentric/verifier-bot test:ci
349+
artifacts:
350+
when: always
351+
reports:
352+
junit: services/verifier-bot/junit.xml
353+
rules:
354+
- !reference [.verifier-bot-workflow, rules]
355+
356+
# The bot consumes the CI-built js SDKs and rs-core-uniffi-web dist (pulled in
357+
# as artifacts); the image build typechecks the bot (see Dockerfile).
358+
verifier-bot-image:
359+
extends:
360+
- .verifier-bot-workflow
361+
- .service-image
362+
needs:
363+
- job: js-sdks-build
364+
artifacts: true
365+
- job: rs-core-uniffi-web-build
366+
artifacts: true
367+
rules:
368+
- !reference [.verifier-bot-workflow, rules]
369+
- !reference [.release-workflow, rules]

apps/polycentric/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ EXPO_PUBLIC_POLYCENTRIC_SEED_SERVERS=http://localhost:3000
99
# registers Expo push tokens with. Falls back to `http://<host>:3001`.
1010
EXPO_PUBLIC_POLYCENTRIC_NOTIFICATION_SERVERS=http://localhost:3001
1111

12+
# Comma-separated list of verifier bots that 'platform' claims will request
13+
# verifications from.
14+
EXPO_PUBLIC_POLYCENTRIC_SEED_VERIFIERS=
15+
1216
# Host Metro and the app resolve `localhost` against. Set to your dev
1317
# machine's LAN IP when running on a physical device/emulator.
1418
REACT_NATIVE_PACKAGER_HOSTNAME=localhost

apps/polycentric/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,11 @@
8383
"devDependencies": {
8484
"@babel/core": "^7.29.7",
8585
"@testing-library/react-native": "^14.0.0",
86-
"@types/jest": "^29.5.14",
86+
"@types/jest": "catalog:",
8787
"@types/node": "catalog:",
8888
"@types/react": "~19.2.17",
8989
"@types/react-test-renderer": "^19.1.0",
90-
"jest": "^29.7.0",
90+
"jest": "catalog:",
9191
"jest-expo": "~57.0.1",
9292
"only-allow": "^1.2.2",
9393
"react-test-renderer": "19.2.3",

compose.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,23 @@ services:
7979
# Generous: the first check waits for the headless browser to come up.
8080
start_period: 20s
8181

82+
verifier-bot:
83+
build:
84+
context: .
85+
# Dockerfile.dev is self-contained: it builds every dependency from source
86+
# inside the image — the Rust->wasm rs-core-uniffi-web and the JS SDKs — so
87+
# no host prebuild or CI artifacts are needed.
88+
dockerfile: services/verifier-bot/Dockerfile.dev
89+
ports:
90+
- ${POLYCENTRIC_VERIFIER_BOT_PORT:-3002}:3002
91+
environment:
92+
- POLYCENTRIC_VERIFIER_BOT_SERVERS=http://server:3000
93+
volumes:
94+
- verifier-bot-state:/app/services/verifier-bot/state
95+
depends_on:
96+
server:
97+
condition: service_started
98+
8299
rustfs:
83100
image: rustfs/rustfs:latest
84101
volumes:
@@ -158,3 +175,4 @@ volumes:
158175
postgres-data:
159176
rustfs-data:
160177
kafka-data:
178+
verifier-bot-state:
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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`.

docs/content/verifiers/overview.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
title: Verifiers
3+
sidebar_label: Overview
4+
sidebar_position: 1
5+
---
6+
7+
# Verifiers
8+
9+
A **verifier** is an automated way to confirm that the owner of a Polycentric
10+
identity also controls an account on some external platform (X, GitHub,
11+
YouTube, …). When a user makes a *claim* ("this Polycentric identity owns the
12+
GitHub account `futo-org`"), the verifier independently checks the platform and,
13+
if the check passes, publishes a signed **verification** vouching for that
14+
claim.
15+
16+
Verifiers run inside the **verifier bot** (`services/verifier-bot`), a small
17+
Node HTTP service. The bot holds its own Polycentric identity and publishes
18+
verifications as that identity, syncing them to the configured Polycentric
19+
server(s).
20+
21+
## How a verification works
22+
23+
1. A user publishes a claim on their identity (e.g. a `Platform` claim naming
24+
their GitHub username).
25+
2. The client asks the bot to verify the claim, referencing it by its
26+
`EventKey`.
27+
3. The bot fetches the claim, runs the platform's verifier:
28+
- **Text** verifiers read the account's public bio/description and check it
29+
contains the user's expected token.
30+
- **OAuth** verifiers have the user sign in to the platform and confirm the
31+
authenticated username matches the claim.
32+
4. If the check passes, the bot publishes a `VerificationVerify` event as its
33+
own identity and pushes it to the servers.
34+
35+
Every platform shares a single claim schema (`Platform`); the platform name is
36+
used only for routing, not to select a schema.
37+
38+
## HTTP API
39+
40+
Routes are derived from each platform's slug (`slug(name)`, e.g. `GitHub`
41+
`github`, `Hacker News``hacker-news`) and its verifier type (`text` or
42+
`oauth`):
43+
44+
| Method & path | Purpose |
45+
|---|---|
46+
| `GET /identity` | The bot's Polycentric identity — `{ identity }` (the identity key it publishes verifications under). Clients use this to recognise and trust the bot's verifications. |
47+
| `GET /platforms` | List platforms as `{ name, slug }`. |
48+
| `GET /platforms/:slug` | Platform detail: `{ verifierType, platform, slug }`. |
49+
| `POST /platforms/:slug/:type/verify` | Verify the referenced claim and publish a verification. |
50+
| `GET /platforms/:slug/:type/health-check` | Run the verifier's self-test. |
51+
| `GET /platforms/:slug/oauth/url` | (OAuth) Get the URL to send the user to. |
52+
| `GET /platforms/:slug/oauth/callback` | (OAuth) Redirect target; bounces back to the web app. |
53+
| `GET /platforms/:slug/oauth/token` | (OAuth) Exchange the OAuth code for a token. |
54+
| `POST /platforms/:slug/text/get-claim-fields-by-url` | (Text) Derive claim fields from a profile URL. |
55+
56+
See [Supported platforms](platforms) for the full list, and
57+
[Creating a verifier](creating-a-verifier) to add a new one.

0 commit comments

Comments
 (0)