diff --git a/bun.lock b/bun.lock index b585e8e4..67cfc6b9 100644 --- a/bun.lock +++ b/bun.lock @@ -72,6 +72,7 @@ "name": "@dither.chat/frontend", "version": "0.0.1", "dependencies": { + "@atomone/dither-api-types": "workspace:*", "@cosmjs/amino": "^0.33.1", "@cosmjs/math": "^0.37.0", "@cosmjs/proto-signing": "^0.37.0", diff --git a/packages/api-main/.env.example b/packages/api-main/.env.example index 406b44ca..cf23a35d 100644 --- a/packages/api-main/.env.example +++ b/packages/api-main/.env.example @@ -8,3 +8,8 @@ TELEGRAM_BOT_USERNAME=@bot_username TELEGRAM_FEED_CHANNEL_ID= TELEGRAM_FEED_CHANNEL_NAME==@channel_name TELEGRAM_MINIAPP_URL=https://dither.chat + +# Bypass social verification for development/testing purposes. Set to false in production. +SKIP_SOCIAL_VERIFICATION=false +X_BEARER_TOKEN=x_token +GITHUB_BEARER_TOKEN=github_token diff --git a/packages/api-main/README.md b/packages/api-main/README.md index 921f3e77..4958ffce 100644 --- a/packages/api-main/README.md +++ b/packages/api-main/README.md @@ -58,3 +58,36 @@ Run the Tests pnpm install pnpm test ``` + +## Social Verification + +Social username verification is asynchronous: + +1. Client submits a proof with `platform`, `username`, and `proof_url`. +2. API stores the link as `pending`. +3. API verifies the proof in background (with retries). +4. Link is updated to `verified` or `failed`. + +### Providers + +**X (Twitter)** + +Uses [X API v2](https://developer.x.com/en/docs/x-api) to fetch tweet data. The proof URL must be a tweet URL (e.g., `https://x.com/username/status/123`). Verification checks: + +- Tweet author matches the claimed username +- Tweet text contains the verification code + +**GitHub** + +Uses [GitHub REST API](https://docs.github.com/en/rest) to fetch gist data. The proof URL must be a gist URL (e.g., `https://gist.github.com/username/gistid`). Verification checks: + +- Gist owner matches the claimed username +- File `dither.md` exists and contains the verification code + +### Environment Variables + +| Variable | Description | +| -------------------------- | -------------------------------------------------- | +| `X_BEARER_TOKEN` | Bearer token for X API authentication | +| `GITHUB_BEARER_TOKEN` | Personal access token for GitHub API | +| `SKIP_SOCIAL_VERIFICATION` | Set to `true` to bypass external checks (dev only) | diff --git a/packages/api-main/drizzle/schema.ts b/packages/api-main/drizzle/schema.ts index 80cde5ba..ddcb08e7 100644 --- a/packages/api-main/drizzle/schema.ts +++ b/packages/api-main/drizzle/schema.ts @@ -131,6 +131,8 @@ export const ModeratorTable = pgTable('moderators', { export const notificationTypeEnum = pgEnum('notification_type', ['like', 'dislike', 'flag', 'follow', 'reply']); +export const socialLinkStatusEnum = pgEnum('social_link_status', ['pending', 'verified', 'failed']); + export const NotificationTable = pgTable( 'notifications', { @@ -154,6 +156,26 @@ export const ReaderState = pgTable('state', { last_block: varchar().notNull(), }); +export const SocialLinksTable = pgTable( + 'social_links', + { + id: serial().primaryKey(), + hash: varchar({ length: 64 }).notNull(), + address: varchar({ length: 44 }).notNull(), + handle: varchar({ length: 128 }).notNull(), + platform: varchar({ length: 32 }).notNull(), + status: socialLinkStatusEnum().notNull().default('pending'), + error_reason: text(), + proof_url: varchar({ length: 512 }).notNull(), + created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + t => [ + index('social_links_address_idx').on(t.address), + index('social_links_handle_idx').on(t.handle), + index('social_links_status_idx').on(t.status), + ], +); + export const tables = [ 'feed', 'likes', @@ -166,4 +188,5 @@ export const tables = [ 'state', 'authrequests', 'ratelimits', + 'social_links', ]; diff --git a/packages/api-main/src/gets/feed.ts b/packages/api-main/src/gets/feed.ts index 81f59779..49fd96ed 100644 --- a/packages/api-main/src/gets/feed.ts +++ b/packages/api-main/src/gets/feed.ts @@ -4,6 +4,7 @@ import { and, count, desc, gte, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; import { FeedTable } from '../../drizzle/schema'; +import { fetchSocialByAddresses } from './social'; const statement = getDatabase() .select() @@ -48,7 +49,11 @@ export async function Feed(query: Gets.FeedQuery) { try { const results = await statement.execute({ offset, limit, minQuantity }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.author)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 400, error: 'failed to read data from database' }; diff --git a/packages/api-main/src/gets/index.ts b/packages/api-main/src/gets/index.ts index 5425689a..aeead5ff 100644 --- a/packages/api-main/src/gets/index.ts +++ b/packages/api-main/src/gets/index.ts @@ -14,3 +14,5 @@ export * from './post'; export * from './posts'; export * from './replies'; export * from './search'; +export * from './socialLinks'; +export * from './socialResolve'; diff --git a/packages/api-main/src/gets/notifications.ts b/packages/api-main/src/gets/notifications.ts index fed7f149..ebd7c64f 100644 --- a/packages/api-main/src/gets/notifications.ts +++ b/packages/api-main/src/gets/notifications.ts @@ -6,6 +6,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; import { NotificationTable } from '../../drizzle/schema'; import { verifyJWT } from '../shared/jwt'; +import { fetchSocialByAddresses } from './social'; const getNotificationsStatement = getDatabase() .select() @@ -38,7 +39,11 @@ export async function Notifications(query: Gets.NotificationsQuery, auth: Cookie try { const results = await getNotificationsStatement.execute({ owner: response, limit, offset }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.actor)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 404, error: 'failed to find matching reply' }; diff --git a/packages/api-main/src/gets/posts.ts b/packages/api-main/src/gets/posts.ts index 9bccbc5e..4df12e87 100644 --- a/packages/api-main/src/gets/posts.ts +++ b/packages/api-main/src/gets/posts.ts @@ -4,6 +4,7 @@ import { and, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; import { FeedTable, FollowsTable } from '../../drizzle/schema'; +import { fetchSocialByAddresses } from './social'; const statement = getDatabase() .select() @@ -85,7 +86,11 @@ export async function FollowingPosts(query: Gets.PostsQuery) { try { const results = await followingPostsStatement.execute({ address: query.address, limit, offset, minQuantity }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.author)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 404, error: 'failed to posts from followed users' }; diff --git a/packages/api-main/src/gets/replies.ts b/packages/api-main/src/gets/replies.ts index 928471a3..4db19d12 100644 --- a/packages/api-main/src/gets/replies.ts +++ b/packages/api-main/src/gets/replies.ts @@ -5,6 +5,7 @@ import { alias } from 'drizzle-orm/pg-core'; import { getDatabase } from '../../drizzle/db'; import { FeedTable } from '../../drizzle/schema'; +import { fetchSocialByAddresses } from './social'; const statement = getDatabase() .select() @@ -40,7 +41,11 @@ export async function Replies(query: Gets.RepliesQuery) { try { const results = await statement.execute({ hash: query.hash, limit, offset, minQuantity }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.author)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 404, error: 'failed to find matching reply' }; diff --git a/packages/api-main/src/gets/social.ts b/packages/api-main/src/gets/social.ts new file mode 100644 index 00000000..0eb5f771 --- /dev/null +++ b/packages/api-main/src/gets/social.ts @@ -0,0 +1,35 @@ +import { and, eq, inArray } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +export type SocialByAddress = Record; + +/** + * Fetch verified social links for a list of addresses. + * Returns a Record keyed by address. Only includes addresses that have at least one verified link. + * Returns an empty object if addresses is empty. + */ +export async function fetchSocialByAddresses(addresses: string[]): Promise { + if (addresses.length === 0) return {}; + + const unique = [...new Set(addresses)]; + + const links = await getDatabase() + .select() + .from(SocialLinksTable) + .where( + and( + inArray(SocialLinksTable.address, unique), + eq(SocialLinksTable.status, SOCIAL_LINK_STATUS.VERIFIED), + ), + ); + + const result: SocialByAddress = {}; + for (const link of links) { + if (!result[link.address]) result[link.address] = []; + result[link.address].push(link); + } + return result; +} diff --git a/packages/api-main/src/gets/socialLinks.ts b/packages/api-main/src/gets/socialLinks.ts new file mode 100644 index 00000000..dd46250a --- /dev/null +++ b/packages/api-main/src/gets/socialLinks.ts @@ -0,0 +1,75 @@ +import type { Gets } from '@atomone/dither-api-types'; + +import { and, eq } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { verifyJWT } from '../shared/jwt'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +/** + * GET /social/links?address=... + * + * Public callers (no auth): returns only verified links. + * Owner (valid JWT cookie matching the requested address): returns all links + */ +export async function SocialLinks(query: Gets.SocialLinksQuery, authToken: string | undefined) { + if (!query.address || query.address.length !== 44) { + return { status: 400, error: 'Valid address is required' }; + } + + const address = query.address.toLowerCase(); + + // Determine if the caller is the owner of this address + let isOwner = false; + if (authToken) { + const tokenAddress = await verifyJWT(authToken); + if (tokenAddress && tokenAddress.toLowerCase() === address) { + isOwner = true; + } + } + + try { + if (isOwner) { + // Owner: all statuses, include error_reason + const rows = await getDatabase() + .select({ + id: SocialLinksTable.id, + handle: SocialLinksTable.handle, + platform: SocialLinksTable.platform, + status: SocialLinksTable.status, + error_reason: SocialLinksTable.error_reason, + proof_url: SocialLinksTable.proof_url, + created_at: SocialLinksTable.created_at, + }) + .from(SocialLinksTable) + .where(eq(SocialLinksTable.address, address)) + .orderBy(SocialLinksTable.created_at); + + return { status: 200, rows }; + } else { + // Public: verified only, no error_reason + const rows = await getDatabase() + .select({ + id: SocialLinksTable.id, + handle: SocialLinksTable.handle, + platform: SocialLinksTable.platform, + status: SocialLinksTable.status, + created_at: SocialLinksTable.created_at, + }) + .from(SocialLinksTable) + .where( + and( + eq(SocialLinksTable.address, address), + eq(SocialLinksTable.status, SOCIAL_LINK_STATUS.VERIFIED), + ), + ) + .orderBy(SocialLinksTable.created_at); + + return { status: 200, rows }; + } + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to read data from database' }; + } +} diff --git a/packages/api-main/src/gets/socialResolve.ts b/packages/api-main/src/gets/socialResolve.ts new file mode 100644 index 00000000..e901b461 --- /dev/null +++ b/packages/api-main/src/gets/socialResolve.ts @@ -0,0 +1,53 @@ +import type { Gets } from '@atomone/dither-api-types'; + +import { and, desc, eq, sql } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +const statementGetSocialResolve = getDatabase() + .select({ + address: SocialLinksTable.address, + handle: SocialLinksTable.handle, + platform: SocialLinksTable.platform, + created_at: SocialLinksTable.created_at, + }) + .from(SocialLinksTable) + .where( + and( + eq(SocialLinksTable.handle, sql.placeholder('handle')), + eq(SocialLinksTable.status, SOCIAL_LINK_STATUS.VERIFIED), + ), + ) + .orderBy(desc(SocialLinksTable.created_at)) + .limit(1) + .prepare('stmnt_get_social_resolve'); + +/** + * GET /social/resolve?handle=alice@x + * + * Returns the wallet address associated with a verified social handle. + * Returns 404 if no verified link is found for the handle. + * If multiple verified entries exist (re-verification history), returns the most recent. + */ +export async function SocialResolve(query: Gets.SocialResolveQuery) { + if (!query.handle || !query.handle.includes('@')) { + return { status: 400, error: 'Valid handle in format "username@platform" is required' }; + } + + const handle = query.handle.toLowerCase(); + + try { + const [row] = await statementGetSocialResolve.execute({ handle }); + + if (!row) { + return { status: 404, error: 'No verified link found for this handle' }; + } + + return { status: 200, address: row.address, handle: row.handle, platform: row.platform }; + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to read data from database' }; + } +} diff --git a/packages/api-main/src/posts/index.ts b/packages/api-main/src/posts/index.ts index d7030b77..4d5ed678 100644 --- a/packages/api-main/src/posts/index.ts +++ b/packages/api-main/src/posts/index.ts @@ -9,5 +9,6 @@ export * from './mod'; export * from './post'; export * from './postRemove'; export * from './reply'; +export * from './social'; export * from './unfollow'; export * from './updateState'; diff --git a/packages/api-main/src/posts/social.ts b/packages/api-main/src/posts/social.ts new file mode 100644 index 00000000..4c61fc87 --- /dev/null +++ b/packages/api-main/src/posts/social.ts @@ -0,0 +1,65 @@ +import type { Posts } from '@atomone/dither-api-types'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { verifyLink } from '../social/verify'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +export async function SocialProof(body: Posts.SocialProofBody) { + if (body.hash.length !== 64) { + return { status: 400, error: 'Provided hash is not valid' }; + } + + if (body.from.length !== 44) { + return { status: 400, error: 'Provided from address is not valid' }; + } + + if (!body.username || body.username.trim().length === 0) { + return { status: 400, error: 'username is required' }; + } + + if (!body.platform || body.platform.trim().length === 0) { + return { status: 400, error: 'platform is required' }; + } + + if (!body.proof_url || body.proof_url.trim().length === 0) { + return { status: 400, error: 'proof_url is required' }; + } + + // handle stored as "username@platform" + const handle = `${body.username.toLowerCase()}@${body.platform.toLowerCase()}`; + + let insertedId: number; + + try { + const [inserted] = await getDatabase() + .insert(SocialLinksTable) + .values({ + hash: body.hash.toLowerCase(), + address: body.from.toLowerCase(), + handle, + platform: body.platform.toLowerCase(), + status: SOCIAL_LINK_STATUS.PENDING, + proof_url: body.proof_url, + created_at: new Date(body.timestamp), + }) + .returning({ id: SocialLinksTable.id }); + + if (!inserted) { + return { status: 500, error: 'failed to insert social link record' }; + } + + insertedId = inserted.id; + } catch (err) { + console.error(err); + return { status: 500, error: 'failed to communicate with database' }; + } + + // Verification runs in background, we don't want to block the API response. + // Future improvement: if we had a job queue system, we could push a job here instead of this. + verifyLink(insertedId, body.platform.toLowerCase(), body.proof_url, body.from.toLowerCase(), body.username.toLowerCase()).catch( + err => console.error(`verifySocialLink error for id=${insertedId}:`, err), + ); + + return { status: 200 }; +} diff --git a/packages/api-main/src/routes/public.ts b/packages/api-main/src/routes/public.ts index 40317c7e..72c2052e 100644 --- a/packages/api-main/src/routes/public.ts +++ b/packages/api-main/src/routes/public.ts @@ -21,4 +21,6 @@ export const publicRoutes = new Elysia() .get('/search', ({ query }) => GetRequests.Search(query), { query: Gets.SearchQuerySchema }) .get('/user-replies', ({ query }) => GetRequests.UserReplies(query), { query: Gets.UserRepliesQuerySchema }) .get('/following-posts', ({ query }) => GetRequests.FollowingPosts(query), { query: Gets.PostsQuerySchema }) - .get('/last-block', GetRequests.LastBlock); + .get('/last-block', GetRequests.LastBlock) + .get('/social/links', ({ query, cookie: { auth } }) => GetRequests.SocialLinks(query, auth.value as string | undefined), { query: Gets.SocialLinksQuerySchema }) + .get('/social/resolve', ({ query }) => GetRequests.SocialResolve(query), { query: Gets.SocialResolveQuerySchema }); diff --git a/packages/api-main/src/routes/reader.ts b/packages/api-main/src/routes/reader.ts index f8e1d973..bce1b264 100644 --- a/packages/api-main/src/routes/reader.ts +++ b/packages/api-main/src/routes/reader.ts @@ -20,4 +20,5 @@ export const readerRoutes = new Elysia() .post('/post-remove', ({ body }) => PostRequests.PostRemove(body), { body: Posts.PostRemoveBodySchema }) .post('/update-state', ({ body }) => PostRequests.UpdateState(body), { body: t.Object({ last_block: t.String() }), - }); + }) + .post('/social/proof', ({ body }) => PostRequests.SocialProof(body), { body: Posts.SocialProofBodySchema }); diff --git a/packages/api-main/src/social/code.ts b/packages/api-main/src/social/code.ts new file mode 100644 index 00000000..f1474c35 --- /dev/null +++ b/packages/api-main/src/social/code.ts @@ -0,0 +1 @@ +export { getSocialProofCode } from '@atomone/dither-api-types'; diff --git a/packages/api-main/src/social/providers/github.ts b/packages/api-main/src/social/providers/github.ts new file mode 100644 index 00000000..dcf0cd1e --- /dev/null +++ b/packages/api-main/src/social/providers/github.ts @@ -0,0 +1,122 @@ +import process from 'node:process'; + +import { getSocialProofCode } from '../code'; + +interface GitHubGistFile { + filename?: string; + type?: string; + language?: string | null; + raw_url?: string; + size?: number; + truncated?: boolean; + content?: string; +} + +interface GitHubGistResponse { + id?: string; + owner?: { + login?: string; + } | null; + files?: Record; +} + +const GITHUB_API_VERSION = '2022-11-28'; +const REQUIRED_PROOF_FILE = 'dither.md'; + +export async function verifyGitHubGist( + proofUrl: string, + address: string, + claimedUsername: string, +): Promise { + const gistId = extractGistId(proofUrl); + if (!gistId) { + throw new Error(`Cannot extract gist ID from proof URL: ${proofUrl}`); + } + + const gist = await fetchGist(gistId); + const gistOwnerUsername = gist.owner?.login; + const proofFile = gist.files?.[REQUIRED_PROOF_FILE]; + + const authorMatches = gistOwnerUsername?.toLowerCase() === claimedUsername.toLowerCase(); + if (!authorMatches || !proofFile) { + return false; + } + + const proofContent = await readGistFileContent(proofFile); + const expectedCode = getSocialProofCode(address); + const codePresent = proofContent.includes(expectedCode); + + return codePresent; +} + +async function fetchGist(gistId: string): Promise { + const response = await fetch(`https://api.github.com/gists/${gistId}`, { + headers: buildGitHubHeaders(), + }); + + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); + } + + return await response.json() as GitHubGistResponse; +} + +async function readGistFileContent(file: GitHubGistFile): Promise { + if (typeof file.content === 'string' && !file.truncated) { + return file.content; + } + + // GitHub can truncate file content in GET /gists/{id}, so fallback to raw_url only in that case. + if (!file.raw_url) { + return file.content ?? ''; + } + + const response = await fetch(file.raw_url, { + headers: buildGitHubHeaders(), + }); + + if (!response.ok) { + throw new Error(`GitHub raw gist fetch returned ${response.status}: ${response.statusText}`); + } + + return await response.text(); +} + +function buildGitHubHeaders(): Record { + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + 'User-Agent': 'dither-chat-verifier', + }; + + const token = process.env.GITHUB_BEARER_TOKEN; + if (!token) { + throw new Error('GITHUB_BEARER_TOKEN environment variable is not set'); + } + + headers.Authorization = `Bearer ${token}`; + return headers; +} + +/** + * Expected format: + * - https://gist.github.com/username/gistid + */ +function extractGistId(proofUrl: string): string | null { + try { + const url = new URL(proofUrl); + if (url.protocol !== 'https:' || url.hostname !== 'gist.github.com') { + return null; + } + + const segments = url.pathname + .split('/') + .filter(Boolean); + + if (segments.length !== 2) return null; + const gistId = segments[1] ?? ''; + return gistId.length > 0 ? gistId : null; + } catch { + return null; + } +} diff --git a/packages/api-main/src/social/providers/x.ts b/packages/api-main/src/social/providers/x.ts new file mode 100644 index 00000000..9f0768b0 --- /dev/null +++ b/packages/api-main/src/social/providers/x.ts @@ -0,0 +1,66 @@ +import process from 'node:process'; + +import { getSocialProofCode } from '../code'; + +interface XTweetResponse { + data?: { + id: string; + text: string; + author_id: string; + }; + includes?: { + users: Array<{ + id: string; + name: string; + username: string; + }>; + }; + errors?: Array<{ message: string }>; +} + +export async function verifyXTweet(proofUrl: string, address: string, claimedUsername: string): Promise { + const tweetId = extractTweetId(proofUrl); + if (!tweetId) { + throw new Error(`Cannot extract tweet ID from proof URL: ${proofUrl}`); + } + + const bearerToken = process.env.X_BEARER_TOKEN; + if (!bearerToken) { + throw new Error('X_BEARER_TOKEN environment variable is not set'); + } + + const url = `https://api.x.com/2/tweets/${tweetId}?expansions=author_id&user.fields=username`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${bearerToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`X API returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json() as XTweetResponse; + + if (!data.data || !data.includes?.users?.[0]) { + throw new Error(`X API response missing expected fields for tweet ${tweetId}`); + } + + const tweetAuthorUsername = data.includes.users[0].username; + const tweetText = data.data.text; + const expectedCode = getSocialProofCode(address); + + const authorMatches = tweetAuthorUsername.toLowerCase() === claimedUsername.toLowerCase(); + const codePresent = tweetText.includes(expectedCode); + + return authorMatches && codePresent; +} + +/** + * Extracts tweet ID from a proof URL. + * Expected format: https://x.com/username/status/1234567890 + */ +function extractTweetId(proofUrl: string): string | null { + const match = proofUrl.match(/\/status\/(\d+)/); + return match ? match[1] : null; +} diff --git a/packages/api-main/src/social/verify.ts b/packages/api-main/src/social/verify.ts new file mode 100644 index 00000000..86486d70 --- /dev/null +++ b/packages/api-main/src/social/verify.ts @@ -0,0 +1,144 @@ +import process from 'node:process'; + +import { and, eq, ne } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { SOCIAL_LINK_ERROR_REASON, SOCIAL_LINK_STATUS } from '../types/social-link'; +import { verifyGitHubGist } from './providers/github'; +import { verifyXTweet } from './providers/x'; + +const MAX_RETRIES = 3; +const INITIAL_DELAY_MS = 2000; // 2 seconds, doubles each retry +const STATUS_VERIFIED = SOCIAL_LINK_STATUS.VERIFIED; +const STATUS_FAILED = SOCIAL_LINK_STATUS.FAILED; +const ERROR_REASON_PROOF_MISMATCH = SOCIAL_LINK_ERROR_REASON.PROOF_MISMATCH; +const ERROR_REASON_VERIFICATION_FAILED = SOCIAL_LINK_ERROR_REASON.VERIFICATION_FAILED; +const ERROR_REASON_HANDLE_ALREADY_CLAIMED = SOCIAL_LINK_ERROR_REASON.HANDLE_ALREADY_CLAIMED; + +/** + * Fetches the proof link, verifies it according to the platform, and updates the DB record with the result. + * Never throws — all errors are caught and result in a 'failed' DB update. + */ +export async function verifyLink( + id: number, + platform: string, + proofUrl: string, + address: string, + username: string, +): Promise { + // Dev bypass: skip external verification and immediately mark as verified. + // Set SKIP_SOCIAL_VERIFICATION=true in .env to enable. + if (process.env.SKIP_SOCIAL_VERIFICATION === 'true') { + if (await isHandleAlreadyClaimed(username, platform, id)) { + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_FAILED, error_reason: ERROR_REASON_HANDLE_ALREADY_CLAIMED }) + .where(eq(SocialLinksTable.id, id)); + return; + } + + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_VERIFIED, error_reason: null }) + .where(eq(SocialLinksTable.id, id)); + return; + } + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + // Retry with exponential delay + if (attempt > 0) { + const delayMs = INITIAL_DELAY_MS * 2 ** (attempt - 1); + await sleep(delayMs); + } + + try { + const verified = await verifyWithProvider(platform, proofUrl, address, username); + + if (verified) { + if (await isHandleAlreadyClaimed(username, platform, id)) { + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_FAILED, error_reason: ERROR_REASON_HANDLE_ALREADY_CLAIMED }) + .where(eq(SocialLinksTable.id, id)); + return; + } + + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_VERIFIED, error_reason: null }) + .where(eq(SocialLinksTable.id, id)); + return; + } + + // Proof check failed (wrong author or missing code) — no point retrying + await getDatabase() + .update(SocialLinksTable) + .set({ + status: STATUS_FAILED, + error_reason: ERROR_REASON_PROOF_MISMATCH, + }) + .where(eq(SocialLinksTable.id, id)); + return; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + console.error(`verifyLink attempt ${attempt + 1}/${MAX_RETRIES} failed for id=${id}:`, lastError.message); + } + } + + // All retries exhausted — mark as failed + try { + await getDatabase() + .update(SocialLinksTable) + .set({ + status: STATUS_FAILED, + error_reason: ERROR_REASON_VERIFICATION_FAILED, + }) + .where(eq(SocialLinksTable.id, id)); + } catch (dbErr) { + console.error(`verifyLink: failed to update DB to failed status for id=${id}:`, dbErr); + } +} + +/** + * Selects the correct provider verify function based on platform. + */ +async function verifyWithProvider( + platform: string, + proofUrl: string, + address: string, + username: string, +): Promise { + switch (platform) { + case 'x': + return verifyXTweet(proofUrl, address, username); + case 'github': + return verifyGitHubGist(proofUrl, address, username); + default: + throw new Error(`Unsupported platform: ${platform}`); + } +} + +// Checks if the given handle is already claimed by another user (excluding the current record ID) +async function isHandleAlreadyClaimed(username: string, platform: string, excludeId: number): Promise { + const handle = `${username}@${platform}`; + const existing = await getDatabase() + .select({ id: SocialLinksTable.id }) + .from(SocialLinksTable) + .where( + and( + eq(SocialLinksTable.handle, handle), + eq(SocialLinksTable.platform, platform), + eq(SocialLinksTable.status, STATUS_VERIFIED), + ne(SocialLinksTable.id, excludeId), + ), + ) + .limit(1); + return existing.length > 0; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/packages/api-main/src/types/social-link.ts b/packages/api-main/src/types/social-link.ts new file mode 100644 index 00000000..f333eaba --- /dev/null +++ b/packages/api-main/src/types/social-link.ts @@ -0,0 +1,11 @@ +export const SOCIAL_LINK_STATUS = { + PENDING: 'pending', + VERIFIED: 'verified', + FAILED: 'failed', +} as const; + +export const SOCIAL_LINK_ERROR_REASON = { + PROOF_MISMATCH: 'proof_mismatch', + VERIFICATION_FAILED: 'verification_failed', + HANDLE_ALREADY_CLAIMED: 'handle_already_claimed', +} as const; diff --git a/packages/api-main/tests/github-provider.test.ts b/packages/api-main/tests/github-provider.test.ts new file mode 100644 index 00000000..23cb3512 --- /dev/null +++ b/packages/api-main/tests/github-provider.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSocialProofCode } from '../src/social/code'; +import { verifyGitHubGist } from '../src/social/providers/github'; + +const address = 'atone1qgdjh6zvzzc8gv3fzk0mu9mezh6gan9hsaxyz1'; +const username = 'dither'; +const gistId = 'abcdef1234567890'; +const proofUrl = `https://gist.github.com/${username}/${gistId}`; +const originalFetch = globalThis.fetch; + +beforeEach(() => { + process.env.GITHUB_BEARER_TOKEN = 'test-token'; +}); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json', + }, + }); +} + +function textResponse(body: string, status = 200) { + return new Response(body, { status }); +} + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('verifyGitHubGist', () => { + it('verifies when owner matches and dither.md contains proof code', async () => { + const proofCode = getSocialProofCode(address); + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + owner: { login: username }, + files: { + 'dither.md': { + filename: 'dither.md', + truncated: false, + content: `verification: ${proofCode}`, + }, + }, + })); + + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + `https://api.github.com/gists/${gistId}`, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }), + }), + ); + }); + + it('returns false when gist owner does not match claimed username', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + owner: { login: 'bob' }, + files: { + 'dither.md': { + truncated: false, + content: `verification: ${getSocialProofCode(address)}`, + }, + }, + })); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(false); + }); + + it('returns false when dither.md is missing', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + owner: { login: username }, + files: { + 'readme.md': { + truncated: false, + content: `verification: ${getSocialProofCode(address)}`, + }, + }, + })); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(false); + }); + + it('fetches raw_url when dither.md content is truncated', async () => { + const proofCode = getSocialProofCode(address); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + owner: { login: username }, + files: { + 'dither.md': { + filename: 'dither.md', + truncated: true, + content: 'partial...', + raw_url: `https://gist.githubusercontent.com/${username}/${gistId}/raw/dither.md`, + }, + }, + })) + .mockResolvedValueOnce(textResponse(`full content ${proofCode}`)); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `https://gist.githubusercontent.com/${username}/${gistId}/raw/dither.md`, + expect.any(Object), + ); + }); + + it('rejects invalid proof URL format', async () => { + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(`https://gist.github.com/${gistId}`, address, username)) + .rejects + .toThrow('Cannot extract gist ID'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws when GitHub API returns non-200', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ message: 'not found' }, 404)); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).rejects.toThrow('GitHub API returned 404'); + }); +}); diff --git a/packages/frontend-main/package.json b/packages/frontend-main/package.json index 05bcffbc..8dfff25f 100644 --- a/packages/frontend-main/package.json +++ b/packages/frontend-main/package.json @@ -10,6 +10,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@atomone/dither-api-types": "workspace:*", "@cosmjs/amino": "^0.33.1", "@cosmjs/math": "^0.37.0", "@cosmjs/proto-signing": "^0.37.0", diff --git a/packages/frontend-main/src/components/posts/PostItem.vue b/packages/frontend-main/src/components/posts/PostItem.vue index b51d17ee..39e6bcad 100644 --- a/packages/frontend-main/src/components/posts/PostItem.vue +++ b/packages/frontend-main/src/components/posts/PostItem.vue @@ -36,7 +36,7 @@ const usedPost = computed(() => cachedPost.value || props.post); -
+
diff --git a/packages/frontend-main/src/components/ui/button/index.ts b/packages/frontend-main/src/components/ui/button/index.ts index 8fa69a75..e0d2d23a 100644 --- a/packages/frontend-main/src/components/ui/button/index.ts +++ b/packages/frontend-main/src/components/ui/button/index.ts @@ -5,7 +5,7 @@ import { cva } from 'class-variance-authority'; export { default as Button } from './Button.vue'; export const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs text-base font-semibold transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs font-semibold transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', { variants: { variant: { @@ -22,10 +22,10 @@ export const buttonVariants = cva( link: 'text-primary underline-offset-4 hover:underline active:underline', }, size: { - default: 'h-13 px-4 has-[>svg]:px-3', - xs: 'h-8 rounded-xs gap-1 px-2 has-[>svg]:px-1.5', - sm: 'h-10 rounded-xs gap-1.5 px-3 has-[>svg]:px-2.5', - lg: 'h-15 rounded-xs px-6 has-[>svg]:px-4', + default: 'h-13 text-base px-4 has-[>svg]:px-3', + xs: 'h-8 text-xs rounded-xs gap-1 px-2 has-[>svg]:px-1.5', + sm: 'h-9 text-sm rounded-xs gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-15 text-base rounded-xs px-6 has-[>svg]:px-4', icon: 'size-10', }, }, diff --git a/packages/frontend-main/src/components/ui/copy/CopyToClipboard.vue b/packages/frontend-main/src/components/ui/copy/CopyToClipboard.vue new file mode 100644 index 00000000..e65af2d7 --- /dev/null +++ b/packages/frontend-main/src/components/ui/copy/CopyToClipboard.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/frontend-main/src/components/ui/copy/index.ts b/packages/frontend-main/src/components/ui/copy/index.ts new file mode 100644 index 00000000..2322ee01 --- /dev/null +++ b/packages/frontend-main/src/components/ui/copy/index.ts @@ -0,0 +1 @@ +export { default as CopyToClipboard } from './CopyToClipboard.vue'; diff --git a/packages/frontend-main/src/components/ui/tooltip/Tooltip.vue b/packages/frontend-main/src/components/ui/tooltip/Tooltip.vue new file mode 100644 index 00000000..bbb6206e --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/Tooltip.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/TooltipContent.vue b/packages/frontend-main/src/components/ui/tooltip/TooltipContent.vue new file mode 100644 index 00000000..e0a0bbe3 --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/TooltipContent.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/TooltipProvider.vue b/packages/frontend-main/src/components/ui/tooltip/TooltipProvider.vue new file mode 100644 index 00000000..81db966f --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/TooltipProvider.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/TooltipTrigger.vue b/packages/frontend-main/src/components/ui/tooltip/TooltipTrigger.vue new file mode 100644 index 00000000..a99008ef --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/TooltipTrigger.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/index.ts b/packages/frontend-main/src/components/ui/tooltip/index.ts new file mode 100644 index 00000000..94e681c8 --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/index.ts @@ -0,0 +1,4 @@ +export { default as Tooltip } from './Tooltip.vue'; +export { default as TooltipContent } from './TooltipContent.vue'; +export { default as TooltipProvider } from './TooltipProvider.vue'; +export { default as TooltipTrigger } from './TooltipTrigger.vue'; diff --git a/packages/frontend-main/src/components/users/UserAvatarUsername.vue b/packages/frontend-main/src/components/users/UserAvatarUsername.vue index 5e50e795..0a5b8b26 100644 --- a/packages/frontend-main/src/components/users/UserAvatarUsername.vue +++ b/packages/frontend-main/src/components/users/UserAvatarUsername.vue @@ -16,6 +16,13 @@ const props = defineProps(); diff --git a/packages/frontend-main/src/components/users/Username.vue b/packages/frontend-main/src/components/users/Username.vue index 18cc59d2..60c9aa43 100644 --- a/packages/frontend-main/src/components/users/Username.vue +++ b/packages/frontend-main/src/components/users/Username.vue @@ -1,12 +1,49 @@ diff --git a/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue b/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue index 99657e7d..02abf04d 100644 --- a/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue +++ b/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue @@ -27,7 +27,11 @@ const connectedState = computed(() => !isConnecting.value && loggedIn.value && !